diff --git a/README.md b/README.md index 271ab6f..ebed66b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ IMAP user and password need to be given for the Nextcloud login. ### Configuration -The parameters are `host, port, sslmode, domain`. +The parameters are `host, port, sslmode, domain, stripDomain, groupDomain, loginOptions`. Possible values for sslmode are `ssl` or `tls`. Add the following to your `config.php`: @@ -75,7 +75,7 @@ Add the following to your `config.php`: array( 'class' => '\OCA\UserExternal\IMAP', 'arguments' => array( - '127.0.0.1', 993, 'ssl', 'example.com', true, false + '127.0.0.1', 993, 'ssl', 'example.com', true, false, 'AUTH=DIGEST-MD5' ), ), ), @@ -91,7 +91,13 @@ is set to true, after successfull login the domain part will be striped and the rest used as username in Nextcloud. e.g. 'username@example.com' will be 'username' in Nextcloud. The sixth parameter toggles whether on creation of the user, it is added to a group corresponding to the name of the domain part -of the address. +of the address. The seventh parameter is optional and allows you to specify +login options, for example which authentication method should be used +(`AUTH=PLAIN`, `AUTH=DIGEST-MD5`, `AUTH=NTLM`, `AUTH=GSSAPI` or `AUTH=*`). +This is useful when the server advertises a method that cannot be used from +Nextcloud (e.g. GSSAPI without Kerberos credentials). The value is passed to +curl as `CURLOPT_LOGIN_OPTIONS`, so the method has to be supported by both the +IMAP server and libcurl. **⚠⚠ Warning:** If you are [**upgrading** from versions **<0.6.0**](https://github.com/nextcloud/user_external/releases/tag/v0.6.0), beside adapting your `config.php` you also have to change the `backend` column in the `users_external` table of the database. In your pre 0.6.0 database it may look like `{127.0.0.1:993/imap/ssl/readonly}INBOX` or similar, but now it has to be just `127.0.0.1` for everything to work flawless again. ⚠⚠ diff --git a/js/user_external-adminSettings.mjs b/js/user_external-adminSettings.mjs index d5842a5..90231f4 100644 --- a/js/user_external-adminSettings.mjs +++ b/js/user_external-adminSettings.mjs @@ -22,5 +22,5 @@ https://vue-select.org/api/props.html#getoptionkey`,e,t)}}},onTab:{type:Function cy="6" r="3" fill="var(--color-main-background)" /> -`,Q1=jt({__name:"NcIconToggleSwitch",props:{checked:{type:Boolean},size:{default:34},inline:{type:Boolean,default:!1}},setup(e){Ir(u=>({"6bd152af":t.value,"16fd8ca9":s.value}));const t=st(()=>e.checked?"var(--color-primary-element)":"var(--color-text-maxcontrast)"),s=st(()=>e.checked?"calc(17 / 24 * 100%)":"calc(7 / 24 * 100%)");return(u,i)=>(M(),Ke(Qh,{class:Ft(u.$style.iconToggleSwitch),svg:Z1,size:u.size,inline:u.inline},null,8,["class","size","inline"]))}}),J1="_iconToggleSwitch_WgcOx",eC={"material-design-icon":"_material-design-icon_ZYrc5",iconToggleSwitch:J1},tC={$style:eC},sC=tt(Q1,[["__cssModules",tC]]),uC=Symbol.for("insideRadioGroup");function iC(){return $s(uC,void 0)}const nC={name:"CheckboxBlankOutlineIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},oC=["aria-hidden","aria-label"],rC=["fill","width","height"],aC={d:"M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"},lC={key:0};function dC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon checkbox-blank-outline-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",aC,[s.title?(M(),G("title",lC,Le(s.title),1)):Ie("",!0)])],8,rC))],16,oC)}const mC=tt(nC,[["render",dC]]),cC={name:"CheckboxMarkedIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},gC=["aria-hidden","aria-label"],fC=["fill","width","height"],hC={d:"M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z"},pC={key:0};function EC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon checkbox-marked-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",hC,[s.title?(M(),G("title",pC,Le(s.title),1)):Ie("",!0)])],8,fC))],16,gC)}const CC=tt(cC,[["render",EC]]),vC={name:"MinusBoxIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},yC=["aria-hidden","aria-label"],BC=["fill","width","height"],xC={d:"M17,13H7V11H17M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z"},wC={key:0};function AC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon minus-box-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",xC,[s.title?(M(),G("title",wC,Le(s.title),1)):Ie("",!0)])],8,BC))],16,yC)}const bC=tt(vC,[["render",AC]]),DC={name:"RadioboxBlankIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},FC=["aria-hidden","aria-label"],kC=["fill","width","height"],NC={d:"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"},SC={key:0};function _C(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon radiobox-blank-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",NC,[s.title?(M(),G("title",SC,Le(s.title),1)):Ie("",!0)])],8,kC))],16,FC)}const TC=tt(DC,[["render",_C]]),OC={name:"RadioboxMarkedIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},IC=["aria-hidden","aria-label"],LC=["fill","width","height"],PC={d:"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7Z"},$C={key:0};function zC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon radiobox-marked-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",PC,[s.title?(M(),G("title",$C,Le(s.title),1)):Ie("",!0)])],8,LC))],16,IC)}const RC=tt(OC,[["render",zC]]),Bu="checkbox",uu="radio",Ps="switch",oi="button",MC={name:"NcCheckboxContent",components:{NcLoadingIcon:ym,NcIconToggleSwitch:sC},props:{iconClass:{type:[String,Object],default:null},textClass:{type:[String,Object],default:null},type:{type:String,default:"checkbox",validator:e=>[Bu,uu,Ps,oi].includes(e)},buttonVariant:{type:Boolean,default:!1},isChecked:{type:Boolean,default:!1},indeterminate:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},iconSize:{type:Number,default:24},labelId:{type:String,required:!0},descriptionId:{type:String,required:!0}},computed:{isButtonType(){return this.type===oi},isSwitchType(){return this.type===Ps},checkboxRadioIconElement(){return this.type===uu?this.isChecked?RC:TC:this.indeterminate?bC:this.isChecked?CC:mC}}},UC={key:0,class:"checkbox-content__wrapper"},VC=["id"],HC=["id"];function KC(e,t,s,u,i,n){const o=Mt("NcLoadingIcon"),l=Mt("NcIconToggleSwitch");return M(),G("span",{class:Ft(["checkbox-content",{["checkbox-content-"+s.type]:!0,"checkbox-content--button-variant":s.buttonVariant,"checkbox-content--has-text":!!e.$slots.default}])},[le("span",{class:Ft(["checkbox-content__icon",{"checkbox-content__icon--checked":s.isChecked,"checkbox-content__icon--has-description":!n.isButtonType&&e.$slots.description,[s.iconClass]:!0}]),"aria-hidden":!0,inert:""},[Fe(e.$slots,"icon",{checked:s.isChecked,loading:s.loading},()=>[s.loading?(M(),Ke(o,{key:0})):n.isSwitchType?(M(),Ke(l,{key:1,checked:s.isChecked,size:s.iconSize,inline:""},null,8,["checked","size"])):s.buttonVariant?Ie("",!0):(M(),Ke(Du(n.checkboxRadioIconElement),{key:2,size:s.iconSize},null,8,["size"]))],!0)],2),e.$slots.default||e.$slots.description?(M(),G("span",UC,[e.$slots.default?(M(),G("span",{key:0,id:s.labelId,class:Ft(["checkbox-content__text",s.textClass])},[Fe(e.$slots,"default",{},void 0,!0)],10,VC)):Ie("",!0),!n.isButtonType&&e.$slots.description?(M(),G("span",{key:1,id:s.descriptionId,class:"checkbox-content__description"},[Fe(e.$slots,"description",{},void 0,!0)],8,HC)):Ie("",!0)])):Ie("",!0)],2)}const YC=tt(MC,[["render",KC],["__scopeId","data-v-a060196e"]]);cu();const Jr={name:"NcCheckboxRadioSwitch",components:{NcCheckboxContent:YC},inheritAttrs:!1,props:{id:{type:String,default:()=>"checkbox-radio-switch-"+si(),validator:e=>e.trim()!==""},wrapperId:{type:String,default:null},name:{type:String,default:null},ariaLabel:{type:String,default:""},type:{type:String,default:"checkbox",validator:e=>[Bu,uu,Ps,oi].includes(e)},buttonVariant:{type:Boolean,default:!1},buttonVariantGrouped:{type:String,default:"no",validator:e=>["no","vertical","horizontal"].includes(e)},modelValue:{type:[Boolean,Array,String],default:!1},value:{type:String,default:null},disabled:{type:Boolean,default:!1},indeterminate:{type:Boolean,default:!1},required:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},wrapperElement:{type:String,default:null},class:{type:[String,Array,Object],default:""},style:{type:[String,Array,Object],default:""},description:{type:String,default:null}},emits:["update:modelValue"],setup(e,{emit:t}){const s=iC();In(()=>s?.value.register(!1));const u=st(()=>s?.value?uu:e.type),i=st({get(){return s?.value?s.value.modelValue:e.modelValue},set(n){s?.value?s.value.onUpdate(n):t("update:modelValue",n)}});return{internalType:u,internalModelValue:i,labelId:si(),descriptionId:si()}},computed:{isButtonType(){return this.internalType===oi},computedWrapperElement(){return this.isButtonType?"button":this.wrapperElement!==null?this.wrapperElement:"span"},listeners(){return this.isButtonType?{click:this.onToggle}:{change:this.onToggle}},iconSize(){return this.internalType===Ps?36:20},cssIconSize(){return this.iconSize+"px"},cssIconHeight(){return this.internalType===Ps?"16px":this.cssIconSize},inputType(){return[Bu,uu,oi].includes(this.internalType)?this.internalType:Bu},isChecked(){return this.value!==null?Array.isArray(this.internalModelValue)?[...this.internalModelValue].indexOf(this.value)>-1:this.internalModelValue===this.value:this.internalModelValue===!0},hasIndeterminate(){return[Bu,uu].includes(this.inputType)}},mounted(){if(this.name&&this.internalType===Bu&&!Array.isArray(this.internalModelValue))throw new Error("When using groups of checkboxes, the updated value will be an array.");if(this.name&&this.internalType===Ps)throw new Error("Switches are not made to be used for data sets. Please use checkboxes instead.");if(typeof this.internalModelValue!="boolean"&&this.internalType===Ps)throw new Error("Switches can only be used with boolean as modelValue prop.")},methods:{t:as,n:_h,onToggle(e){if(this.disabled||e.target.tagName.toLowerCase()==="a")return;if(this.internalType===uu){this.internalModelValue=this.value;return}if(this.internalType===Ps){this.internalModelValue=!this.isChecked;return}if(typeof this.internalModelValue=="boolean"){this.internalModelValue=!this.internalModelValue;return}const t=this.getInputsSet().filter(s=>s.checked).map(s=>s.value);t.includes(this.value)?this.internalModelValue=t.filter(s=>s!==this.value):this.internalModelValue=[...t,this.value]},getInputsSet(){return[...document.getElementsByName(this.name)]}}},gl=()=>{Ir(e=>({"1d6eb36d":e.cssIconSize,"698a3993":e.cssIconHeight}))},fl=Jr.setup;Jr.setup=fl?(e,t)=>(gl(),fl(e,t)):gl;const GC=["id","aria-labelledby","aria-describedby","aria-label","disabled","type","value","checked",".indeterminate","required","name"];function WC(e,t,s,u,i,n){const o=Mt("NcCheckboxContent");return M(),Ke(Du(n.computedWrapperElement),je({id:s.wrapperId??(n.isButtonType?s.id:null),"aria-label":n.isButtonType&&s.ariaLabel?s.ariaLabel:void 0,class:["checkbox-radio-switch",[e.$props.class,{["checkbox-radio-switch-"+u.internalType]:u.internalType,"checkbox-radio-switch--checked":n.isChecked,"checkbox-radio-switch--disabled":s.disabled,"checkbox-radio-switch--indeterminate":n.hasIndeterminate?s.indeterminate:!1,"checkbox-radio-switch--button-variant":s.buttonVariant,"checkbox-radio-switch--button-variant-v-grouped":s.buttonVariant&&s.buttonVariantGrouped==="vertical","checkbox-radio-switch--button-variant-h-grouped":s.buttonVariant&&s.buttonVariantGrouped==="horizontal","button-vue":n.isButtonType}]],style:s.style,type:n.isButtonType?"button":null},n.isButtonType?e.$attrs:{},ln(n.isButtonType?n.listeners:{})),{default:Me(()=>[n.isButtonType?Ie("",!0):(M(),G("input",je({key:0,id:s.id,"aria-labelledby":!n.isButtonType&&!s.ariaLabel?u.labelId:null,"aria-describedby":!n.isButtonType&&(s.description||e.$slots.description)?u.descriptionId:null,"aria-label":s.ariaLabel||void 0,class:"checkbox-radio-switch__input",disabled:s.disabled,type:n.inputType,value:s.value,checked:n.isChecked,".indeterminate":n.hasIndeterminate?s.indeterminate:null,required:s.required,name:s.name},e.$attrs,ln(n.listeners,!0)),null,48,GC)),Ne(o,{id:n.isButtonType?void 0:`${s.id}-label`,class:"checkbox-radio-switch__content",iconClass:"checkbox-radio-switch__icon",textClass:"checkbox-radio-switch__text",type:u.internalType,indeterminate:n.hasIndeterminate?s.indeterminate:!1,buttonVariant:s.buttonVariant,isChecked:n.isChecked,loading:s.loading,labelId:u.labelId,descriptionId:u.descriptionId,iconSize:n.iconSize,onClick:n.onToggle},nd({icon:Me(()=>[Fe(e.$slots,"icon",{},void 0,!0)]),_:2},[e.$slots.description||s.description?{name:"description",fn:Me(()=>[Fe(e.$slots,"description",{},()=>[gs(Le(s.description),1)],!0)]),key:"0"}:void 0,e.$slots.default?{name:"default",fn:Me(()=>[Fe(e.$slots,"default",{},void 0,!0)]),key:"1"}:void 0]),1032,["id","type","indeterminate","buttonVariant","isChecked","loading","labelId","descriptionId","iconSize","onClick"])]),_:3},16,["id","aria-label","class","style","type"])}const qC=tt(Jr,[["render",WC],["__scopeId","data-v-6808cde4"]]),e0=(e,t)=>{const s=e.__vccOpts||e;for(const[u,i]of t)s[u]=i;return s},XC={name:"CloseIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},jC=["aria-hidden","aria-label"],ZC=["fill","width","height"],QC={d:"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"},JC={key:0};function ev(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon close-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",QC,[s.title?(M(),G("title",JC,Le(s.title),1)):Ie("",!0)])],8,ZC))],16,jC)}const tv=e0(XC,[["render",ev]]),sv={class:"backend-entry"},uv={class:"backend-entry__header"},iv={class:"backend-entry__fields"},nv=["for"],ov=["id","type","value","required","onInput"],rv={class:"backend-entry__footer"},av=jt({__name:"BackendEntry",props:{uid:{},backendClass:{},spec:{},values:{},saving:{type:Boolean}},emits:["update","remove","save"],setup(e,{emit:t}){const s=e,u=t;function i(o,l){u("update",o,l);const d=s.spec.sslPortMap;if(d){if(o==="sslmode"){const m=d[l];m!==void 0&&u("update","port",m)}else if(o==="port"){const m=Number(l),a=Object.entries(d).filter(([,f])=>f===m).map(([f])=>f);a.length===1&&u("update","sslmode",a[0])}}}function n(o){const l=s.values[o.key]??o.default??"",d=(o.options||[]).indexOf(l),m=d>=0&&o.optionLabels?.[d]||l||ft("user_external","(none)");return{id:l,label:m}}return(o,l)=>(M(),G("div",sv,[le("div",uv,[le("strong",null,Le(e.spec.label),1),Ne(He(za),{type:"tertiary-no-background","aria-label":He(ft)("user_external","Remove backend"),onClick:l[0]||(l[0]=d=>o.$emit("remove"))},{icon:Me(()=>[Ne(tv,{size:20})]),_:1},8,["aria-label"])]),le("div",iv,[(M(!0),G(Xe,null,hi(e.spec.fields,d=>(M(),G("div",{key:d.key,class:"backend-entry__field"},[le("label",{for:`${e.uid}-${d.key}`},Le(d.label),9,nv),d.type==="checkbox"?(M(),Ke(He(qC),{key:0,id:`${e.uid}-${d.key}`,"model-value":!!e.values[d.key],"onUpdate:modelValue":m=>i(d.key,m)},{default:Me(()=>[gs(Le(d.label),1)]),_:2},1032,["id","model-value","onUpdate:modelValue"])):d.type==="select"?(M(),Ke(He(Qm),{key:1,id:`${e.uid}-${d.key}`,"model-value":n(d),options:(d.options||[]).map((m,a)=>({id:m,label:d.optionLabels?.[a]||m||He(ft)("user_external","(none)")})),clearable:!1,"onUpdate:modelValue":m=>i(d.key,m?.id)},null,8,["id","model-value","options","onUpdate:modelValue"])):(M(),G("input",{key:2,id:`${e.uid}-${d.key}`,type:d.type==="password"?"password":d.type==="number"?"number":"text",value:e.values[d.key],required:d.required,class:"backend-entry__input",onInput:m=>i(d.key,m.target.value)},null,40,ov))]))),128))]),le("div",rv,[Ne(He(za),{type:"primary",disabled:e.saving,onClick:l[1]||(l[1]=d=>o.$emit("save"))},{default:Me(()=>[gs(Le(e.saving?He(ft)("user_external","Saving…"):He(ft)("user_external","Save")),1)]),_:1},8,["disabled"])])]))}}),lv=e0(av,[["__scopeId","data-v-f3818bb3"]]),ri={"\\OCA\\UserExternal\\IMAP":{label:"IMAP",fields:[{key:"host",label:"IMAP server",type:"text",required:!0},{key:"port",label:"Port",type:"number",default:143},{key:"sslmode",label:"SSL mode",type:"select",options:["","ssl","tls"],optionLabels:["(None)","SSL/TLS","STARTTLS"],default:""},{key:"domain",label:"Email domain restriction",type:"text",default:""},{key:"stripDomain",label:"Strip domain from username",type:"checkbox",default:!0},{key:"groupDomain",label:"Create group per domain",type:"checkbox",default:!1}],sslPortMap:{"":143,tls:143,ssl:993}},"\\OCA\\UserExternal\\FTP":{label:"FTP",fields:[{key:"host",label:"FTP server",type:"text",required:!0},{key:"secure",label:"Use FTPS (secure)",type:"checkbox",default:!1}]},"\\OCA\\UserExternal\\SMB":{label:"SMB / Windows",fields:[{key:"host",label:"SMB server",type:"text",required:!0}]},"\\OCA\\UserExternal\\SSH":{label:"SSH",fields:[{key:"host",label:"SSH server",type:"text",required:!0},{key:"port",label:"Port",type:"number",default:22}]},"\\OCA\\UserExternal\\BasicAuth":{label:"HTTP Basic Auth",fields:[{key:"url",label:"Auth URL",type:"text",required:!0}]},"\\OCA\\UserExternal\\WebDavAuth":{label:"WebDAV",fields:[{key:"url",label:"WebDAV URL",type:"text",required:!0}]},"\\OCA\\UserExternal\\XMPP":{label:"XMPP (Prosody)",fields:[{key:"host",label:"XMPP domain",type:"text",required:!0},{key:"dbName",label:"Database name",type:"text",required:!0},{key:"dbUser",label:"Database user",type:"text",required:!0},{key:"dbPassword",label:"Database password",type:"password",required:!0},{key:"xmppDomain",label:"XMPP domain filter",type:"text",required:!0},{key:"passwordHashed",label:"Passwords are hashed",type:"checkbox",default:!0}]}};function dv(e,t){return ri[e].fields.map(s=>t[s.key]??s.default??"")}function mv(e,t){const s=ri[e].fields;return Object.fromEntries(s.map((u,i)=>[u.key,t[i]??u.default??""]))}const cv={class:"user-external-admin"},gv={key:0,class:"backends-empty"},fv={class:"backends-actions"},hv=jt({__name:"AdminSettings",setup(e){const t=Pg("user_external","backends",[]),s=ci(t.map(m=>({class:m.class,fields:mv(m.class,m.arguments)}))),u=ci(!1),i=Object.entries(ri).map(([m,a])=>({id:m,label:a.label}));function n(m){if(!m)return;const a=Object.fromEntries(ri[m.id].fields.map(f=>[f.key,f.default??""]));s.value.push({class:m.id,fields:a})}function o(m){s.value.splice(m,1)}function l(m,a,f){s.value[m].fields[a]=f}async function d(){u.value=!0;try{const m=s.value.map(a=>({class:a.class,arguments:dv(a.class,a.fields)}));await yu.put(Td("/apps/user_external/api/v1/backends"),{backends:m}),nE(ft("user_external","Settings saved."))}catch(m){iE(ft("user_external","Failed to save settings.")),console.error(m)}finally{u.value=!1}}return(m,a)=>(M(),G("div",cv,[Ne(He(j1),{name:He(ft)("user_external","External authentication backends"),description:He(ft)("user_external","Configure the external services used to authenticate users. Changes are written to config.php and take effect immediately.")},{default:Me(()=>[(M(!0),G(Xe,null,hi(s.value,(f,E)=>(M(),Ke(lv,{key:E,uid:`backend-${E}`,"backend-class":f.class,spec:He(ri)[f.class],values:f.fields,saving:u.value,onUpdate:(h,B)=>l(E,h,B),onRemove:h=>o(E),onSave:d},null,8,["uid","backend-class","spec","values","saving","onUpdate","onRemove"]))),128)),s.value.length===0?(M(),G("div",gv,Le(He(ft)("user_external","No backends configured. Add one below.")),1)):Ie("",!0),le("div",fv,[Ne(He(Qm),{options:He(i),placeholder:He(ft)("user_external","Select backend type to add…"),label:"label",clearable:!1,class:"backends-actions__select","onUpdate:modelValue":n},null,8,["options","placeholder"])])]),_:1},8,["name","description"])]))}}),pv=e0(hv,[["__scopeId","data-v-b2d9a7f8"]]),Ev=Og(pv);Ev.mount("#user-external-admin-settings"); +`,Q1=jt({__name:"NcIconToggleSwitch",props:{checked:{type:Boolean},size:{default:34},inline:{type:Boolean,default:!1}},setup(e){Ir(u=>({"6bd152af":t.value,"16fd8ca9":s.value}));const t=st(()=>e.checked?"var(--color-primary-element)":"var(--color-text-maxcontrast)"),s=st(()=>e.checked?"calc(17 / 24 * 100%)":"calc(7 / 24 * 100%)");return(u,i)=>(M(),Ke(Qh,{class:Ft(u.$style.iconToggleSwitch),svg:Z1,size:u.size,inline:u.inline},null,8,["class","size","inline"]))}}),J1="_iconToggleSwitch_WgcOx",eC={"material-design-icon":"_material-design-icon_ZYrc5",iconToggleSwitch:J1},tC={$style:eC},sC=tt(Q1,[["__cssModules",tC]]),uC=Symbol.for("insideRadioGroup");function iC(){return $s(uC,void 0)}const nC={name:"CheckboxBlankOutlineIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},oC=["aria-hidden","aria-label"],rC=["fill","width","height"],aC={d:"M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"},lC={key:0};function dC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon checkbox-blank-outline-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",aC,[s.title?(M(),G("title",lC,Le(s.title),1)):Ie("",!0)])],8,rC))],16,oC)}const mC=tt(nC,[["render",dC]]),cC={name:"CheckboxMarkedIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},gC=["aria-hidden","aria-label"],fC=["fill","width","height"],hC={d:"M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z"},pC={key:0};function EC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon checkbox-marked-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",hC,[s.title?(M(),G("title",pC,Le(s.title),1)):Ie("",!0)])],8,fC))],16,gC)}const CC=tt(cC,[["render",EC]]),vC={name:"MinusBoxIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},yC=["aria-hidden","aria-label"],BC=["fill","width","height"],xC={d:"M17,13H7V11H17M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z"},wC={key:0};function AC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon minus-box-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",xC,[s.title?(M(),G("title",wC,Le(s.title),1)):Ie("",!0)])],8,BC))],16,yC)}const bC=tt(vC,[["render",AC]]),DC={name:"RadioboxBlankIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},FC=["aria-hidden","aria-label"],kC=["fill","width","height"],NC={d:"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"},SC={key:0};function _C(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon radiobox-blank-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",NC,[s.title?(M(),G("title",SC,Le(s.title),1)):Ie("",!0)])],8,kC))],16,FC)}const TC=tt(DC,[["render",_C]]),OC={name:"RadioboxMarkedIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},IC=["aria-hidden","aria-label"],LC=["fill","width","height"],PC={d:"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7Z"},$C={key:0};function zC(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon radiobox-marked-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",PC,[s.title?(M(),G("title",$C,Le(s.title),1)):Ie("",!0)])],8,LC))],16,IC)}const RC=tt(OC,[["render",zC]]),Bu="checkbox",uu="radio",Ps="switch",oi="button",MC={name:"NcCheckboxContent",components:{NcLoadingIcon:ym,NcIconToggleSwitch:sC},props:{iconClass:{type:[String,Object],default:null},textClass:{type:[String,Object],default:null},type:{type:String,default:"checkbox",validator:e=>[Bu,uu,Ps,oi].includes(e)},buttonVariant:{type:Boolean,default:!1},isChecked:{type:Boolean,default:!1},indeterminate:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},iconSize:{type:Number,default:24},labelId:{type:String,required:!0},descriptionId:{type:String,required:!0}},computed:{isButtonType(){return this.type===oi},isSwitchType(){return this.type===Ps},checkboxRadioIconElement(){return this.type===uu?this.isChecked?RC:TC:this.indeterminate?bC:this.isChecked?CC:mC}}},UC={key:0,class:"checkbox-content__wrapper"},VC=["id"],HC=["id"];function KC(e,t,s,u,i,n){const o=Mt("NcLoadingIcon"),l=Mt("NcIconToggleSwitch");return M(),G("span",{class:Ft(["checkbox-content",{["checkbox-content-"+s.type]:!0,"checkbox-content--button-variant":s.buttonVariant,"checkbox-content--has-text":!!e.$slots.default}])},[le("span",{class:Ft(["checkbox-content__icon",{"checkbox-content__icon--checked":s.isChecked,"checkbox-content__icon--has-description":!n.isButtonType&&e.$slots.description,[s.iconClass]:!0}]),"aria-hidden":!0,inert:""},[Fe(e.$slots,"icon",{checked:s.isChecked,loading:s.loading},()=>[s.loading?(M(),Ke(o,{key:0})):n.isSwitchType?(M(),Ke(l,{key:1,checked:s.isChecked,size:s.iconSize,inline:""},null,8,["checked","size"])):s.buttonVariant?Ie("",!0):(M(),Ke(Du(n.checkboxRadioIconElement),{key:2,size:s.iconSize},null,8,["size"]))],!0)],2),e.$slots.default||e.$slots.description?(M(),G("span",UC,[e.$slots.default?(M(),G("span",{key:0,id:s.labelId,class:Ft(["checkbox-content__text",s.textClass])},[Fe(e.$slots,"default",{},void 0,!0)],10,VC)):Ie("",!0),!n.isButtonType&&e.$slots.description?(M(),G("span",{key:1,id:s.descriptionId,class:"checkbox-content__description"},[Fe(e.$slots,"description",{},void 0,!0)],8,HC)):Ie("",!0)])):Ie("",!0)],2)}const YC=tt(MC,[["render",KC],["__scopeId","data-v-a060196e"]]);cu();const Jr={name:"NcCheckboxRadioSwitch",components:{NcCheckboxContent:YC},inheritAttrs:!1,props:{id:{type:String,default:()=>"checkbox-radio-switch-"+si(),validator:e=>e.trim()!==""},wrapperId:{type:String,default:null},name:{type:String,default:null},ariaLabel:{type:String,default:""},type:{type:String,default:"checkbox",validator:e=>[Bu,uu,Ps,oi].includes(e)},buttonVariant:{type:Boolean,default:!1},buttonVariantGrouped:{type:String,default:"no",validator:e=>["no","vertical","horizontal"].includes(e)},modelValue:{type:[Boolean,Array,String],default:!1},value:{type:String,default:null},disabled:{type:Boolean,default:!1},indeterminate:{type:Boolean,default:!1},required:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},wrapperElement:{type:String,default:null},class:{type:[String,Array,Object],default:""},style:{type:[String,Array,Object],default:""},description:{type:String,default:null}},emits:["update:modelValue"],setup(e,{emit:t}){const s=iC();In(()=>s?.value.register(!1));const u=st(()=>s?.value?uu:e.type),i=st({get(){return s?.value?s.value.modelValue:e.modelValue},set(n){s?.value?s.value.onUpdate(n):t("update:modelValue",n)}});return{internalType:u,internalModelValue:i,labelId:si(),descriptionId:si()}},computed:{isButtonType(){return this.internalType===oi},computedWrapperElement(){return this.isButtonType?"button":this.wrapperElement!==null?this.wrapperElement:"span"},listeners(){return this.isButtonType?{click:this.onToggle}:{change:this.onToggle}},iconSize(){return this.internalType===Ps?36:20},cssIconSize(){return this.iconSize+"px"},cssIconHeight(){return this.internalType===Ps?"16px":this.cssIconSize},inputType(){return[Bu,uu,oi].includes(this.internalType)?this.internalType:Bu},isChecked(){return this.value!==null?Array.isArray(this.internalModelValue)?[...this.internalModelValue].indexOf(this.value)>-1:this.internalModelValue===this.value:this.internalModelValue===!0},hasIndeterminate(){return[Bu,uu].includes(this.inputType)}},mounted(){if(this.name&&this.internalType===Bu&&!Array.isArray(this.internalModelValue))throw new Error("When using groups of checkboxes, the updated value will be an array.");if(this.name&&this.internalType===Ps)throw new Error("Switches are not made to be used for data sets. Please use checkboxes instead.");if(typeof this.internalModelValue!="boolean"&&this.internalType===Ps)throw new Error("Switches can only be used with boolean as modelValue prop.")},methods:{t:as,n:_h,onToggle(e){if(this.disabled||e.target.tagName.toLowerCase()==="a")return;if(this.internalType===uu){this.internalModelValue=this.value;return}if(this.internalType===Ps){this.internalModelValue=!this.isChecked;return}if(typeof this.internalModelValue=="boolean"){this.internalModelValue=!this.internalModelValue;return}const t=this.getInputsSet().filter(s=>s.checked).map(s=>s.value);t.includes(this.value)?this.internalModelValue=t.filter(s=>s!==this.value):this.internalModelValue=[...t,this.value]},getInputsSet(){return[...document.getElementsByName(this.name)]}}},gl=()=>{Ir(e=>({"1d6eb36d":e.cssIconSize,"698a3993":e.cssIconHeight}))},fl=Jr.setup;Jr.setup=fl?(e,t)=>(gl(),fl(e,t)):gl;const GC=["id","aria-labelledby","aria-describedby","aria-label","disabled","type","value","checked",".indeterminate","required","name"];function WC(e,t,s,u,i,n){const o=Mt("NcCheckboxContent");return M(),Ke(Du(n.computedWrapperElement),je({id:s.wrapperId??(n.isButtonType?s.id:null),"aria-label":n.isButtonType&&s.ariaLabel?s.ariaLabel:void 0,class:["checkbox-radio-switch",[e.$props.class,{["checkbox-radio-switch-"+u.internalType]:u.internalType,"checkbox-radio-switch--checked":n.isChecked,"checkbox-radio-switch--disabled":s.disabled,"checkbox-radio-switch--indeterminate":n.hasIndeterminate?s.indeterminate:!1,"checkbox-radio-switch--button-variant":s.buttonVariant,"checkbox-radio-switch--button-variant-v-grouped":s.buttonVariant&&s.buttonVariantGrouped==="vertical","checkbox-radio-switch--button-variant-h-grouped":s.buttonVariant&&s.buttonVariantGrouped==="horizontal","button-vue":n.isButtonType}]],style:s.style,type:n.isButtonType?"button":null},n.isButtonType?e.$attrs:{},ln(n.isButtonType?n.listeners:{})),{default:Me(()=>[n.isButtonType?Ie("",!0):(M(),G("input",je({key:0,id:s.id,"aria-labelledby":!n.isButtonType&&!s.ariaLabel?u.labelId:null,"aria-describedby":!n.isButtonType&&(s.description||e.$slots.description)?u.descriptionId:null,"aria-label":s.ariaLabel||void 0,class:"checkbox-radio-switch__input",disabled:s.disabled,type:n.inputType,value:s.value,checked:n.isChecked,".indeterminate":n.hasIndeterminate?s.indeterminate:null,required:s.required,name:s.name},e.$attrs,ln(n.listeners,!0)),null,48,GC)),Ne(o,{id:n.isButtonType?void 0:`${s.id}-label`,class:"checkbox-radio-switch__content",iconClass:"checkbox-radio-switch__icon",textClass:"checkbox-radio-switch__text",type:u.internalType,indeterminate:n.hasIndeterminate?s.indeterminate:!1,buttonVariant:s.buttonVariant,isChecked:n.isChecked,loading:s.loading,labelId:u.labelId,descriptionId:u.descriptionId,iconSize:n.iconSize,onClick:n.onToggle},nd({icon:Me(()=>[Fe(e.$slots,"icon",{},void 0,!0)]),_:2},[e.$slots.description||s.description?{name:"description",fn:Me(()=>[Fe(e.$slots,"description",{},()=>[gs(Le(s.description),1)],!0)]),key:"0"}:void 0,e.$slots.default?{name:"default",fn:Me(()=>[Fe(e.$slots,"default",{},void 0,!0)]),key:"1"}:void 0]),1032,["id","type","indeterminate","buttonVariant","isChecked","loading","labelId","descriptionId","iconSize","onClick"])]),_:3},16,["id","aria-label","class","style","type"])}const qC=tt(Jr,[["render",WC],["__scopeId","data-v-6808cde4"]]),e0=(e,t)=>{const s=e.__vccOpts||e;for(const[u,i]of t)s[u]=i;return s},XC={name:"CloseIcon",emits:["click"],props:{title:{type:String},fillColor:{type:String,default:"currentColor"},size:{type:Number,default:24}}},jC=["aria-hidden","aria-label"],ZC=["fill","width","height"],QC={d:"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"},JC={key:0};function ev(e,t,s,u,i,n){return M(),G("span",je(e.$attrs,{"aria-hidden":s.title?null:"true","aria-label":s.title,class:"material-design-icon close-icon",role:"img",onClick:t[0]||(t[0]=o=>e.$emit("click",o))}),[(M(),G("svg",{fill:s.fillColor,class:"material-design-icon__svg",width:s.size,height:s.size,viewBox:"0 0 24 24"},[le("path",QC,[s.title?(M(),G("title",JC,Le(s.title),1)):Ie("",!0)])],8,ZC))],16,jC)}const tv=e0(XC,[["render",ev]]),sv={class:"backend-entry"},uv={class:"backend-entry__header"},iv={class:"backend-entry__fields"},nv=["for"],ov=["id","type","value","required","onInput"],rv={class:"backend-entry__footer"},av=jt({__name:"BackendEntry",props:{uid:{},backendClass:{},spec:{},values:{},saving:{type:Boolean}},emits:["update","remove","save"],setup(e,{emit:t}){const s=e,u=t;function i(o,l){u("update",o,l);const d=s.spec.sslPortMap;if(d){if(o==="sslmode"){const m=d[l];m!==void 0&&u("update","port",m)}else if(o==="port"){const m=Number(l),a=Object.entries(d).filter(([,f])=>f===m).map(([f])=>f);a.length===1&&u("update","sslmode",a[0])}}}function n(o){const l=s.values[o.key]??o.default??"",d=(o.options||[]).indexOf(l),m=d>=0&&o.optionLabels?.[d]||l||ft("user_external","(none)");return{id:l,label:m}}return(o,l)=>(M(),G("div",sv,[le("div",uv,[le("strong",null,Le(e.spec.label),1),Ne(He(za),{type:"tertiary-no-background","aria-label":He(ft)("user_external","Remove backend"),onClick:l[0]||(l[0]=d=>o.$emit("remove"))},{icon:Me(()=>[Ne(tv,{size:20})]),_:1},8,["aria-label"])]),le("div",iv,[(M(!0),G(Xe,null,hi(e.spec.fields,d=>(M(),G("div",{key:d.key,class:"backend-entry__field"},[le("label",{for:`${e.uid}-${d.key}`},Le(d.label),9,nv),d.type==="checkbox"?(M(),Ke(He(qC),{key:0,id:`${e.uid}-${d.key}`,"model-value":!!e.values[d.key],"onUpdate:modelValue":m=>i(d.key,m)},{default:Me(()=>[gs(Le(d.label),1)]),_:2},1032,["id","model-value","onUpdate:modelValue"])):d.type==="select"?(M(),Ke(He(Qm),{key:1,id:`${e.uid}-${d.key}`,"model-value":n(d),options:(d.options||[]).map((m,a)=>({id:m,label:d.optionLabels?.[a]||m||He(ft)("user_external","(none)")})),clearable:!1,"onUpdate:modelValue":m=>i(d.key,m?.id)},null,8,["id","model-value","options","onUpdate:modelValue"])):(M(),G("input",{key:2,id:`${e.uid}-${d.key}`,type:d.type==="password"?"password":d.type==="number"?"number":"text",value:e.values[d.key],required:d.required,class:"backend-entry__input",onInput:m=>i(d.key,m.target.value)},null,40,ov))]))),128))]),le("div",rv,[Ne(He(za),{type:"primary",disabled:e.saving,onClick:l[1]||(l[1]=d=>o.$emit("save"))},{default:Me(()=>[gs(Le(e.saving?He(ft)("user_external","Saving…"):He(ft)("user_external","Save")),1)]),_:1},8,["disabled"])])]))}}),lv=e0(av,[["__scopeId","data-v-f3818bb3"]]),ri={"\\OCA\\UserExternal\\IMAP":{label:"IMAP",fields:[{key:"host",label:"IMAP server",type:"text",required:!0},{key:"port",label:"Port",type:"number",default:143},{key:"sslmode",label:"SSL mode",type:"select",options:["","ssl","tls"],optionLabels:["(None)","SSL/TLS","STARTTLS"],default:""},{key:"domain",label:"Email domain restriction",type:"text",default:""},{key:"stripDomain",label:"Strip domain from username",type:"checkbox",default:!0},{key:"groupDomain",label:"Create group per domain",type:"checkbox",default:!1},{key:"loginOptions",label:"Login options (e.g. AUTH=DIGEST-MD5)",type:"text",default:""}],sslPortMap:{"":143,tls:143,ssl:993}},"\\OCA\\UserExternal\\FTP":{label:"FTP",fields:[{key:"host",label:"FTP server",type:"text",required:!0},{key:"secure",label:"Use FTPS (secure)",type:"checkbox",default:!1}]},"\\OCA\\UserExternal\\SMB":{label:"SMB / Windows",fields:[{key:"host",label:"SMB server",type:"text",required:!0}]},"\\OCA\\UserExternal\\SSH":{label:"SSH",fields:[{key:"host",label:"SSH server",type:"text",required:!0},{key:"port",label:"Port",type:"number",default:22}]},"\\OCA\\UserExternal\\BasicAuth":{label:"HTTP Basic Auth",fields:[{key:"url",label:"Auth URL",type:"text",required:!0}]},"\\OCA\\UserExternal\\WebDavAuth":{label:"WebDAV",fields:[{key:"url",label:"WebDAV URL",type:"text",required:!0}]},"\\OCA\\UserExternal\\XMPP":{label:"XMPP (Prosody)",fields:[{key:"host",label:"XMPP domain",type:"text",required:!0},{key:"dbName",label:"Database name",type:"text",required:!0},{key:"dbUser",label:"Database user",type:"text",required:!0},{key:"dbPassword",label:"Database password",type:"password",required:!0},{key:"xmppDomain",label:"XMPP domain filter",type:"text",required:!0},{key:"passwordHashed",label:"Passwords are hashed",type:"checkbox",default:!0}]}};function dv(e,t){return ri[e].fields.map(s=>t[s.key]??s.default??"")}function mv(e,t){const s=ri[e].fields;return Object.fromEntries(s.map((u,i)=>[u.key,t[i]??u.default??""]))}const cv={class:"user-external-admin"},gv={key:0,class:"backends-empty"},fv={class:"backends-actions"},hv=jt({__name:"AdminSettings",setup(e){const t=Pg("user_external","backends",[]),s=ci(t.map(m=>({class:m.class,fields:mv(m.class,m.arguments)}))),u=ci(!1),i=Object.entries(ri).map(([m,a])=>({id:m,label:a.label}));function n(m){if(!m)return;const a=Object.fromEntries(ri[m.id].fields.map(f=>[f.key,f.default??""]));s.value.push({class:m.id,fields:a})}function o(m){s.value.splice(m,1)}function l(m,a,f){s.value[m].fields[a]=f}async function d(){u.value=!0;try{const m=s.value.map(a=>({class:a.class,arguments:dv(a.class,a.fields)}));await yu.put(Td("/apps/user_external/api/v1/backends"),{backends:m}),nE(ft("user_external","Settings saved."))}catch(m){iE(ft("user_external","Failed to save settings.")),console.error(m)}finally{u.value=!1}}return(m,a)=>(M(),G("div",cv,[Ne(He(j1),{name:He(ft)("user_external","External authentication backends"),description:He(ft)("user_external","Configure the external services used to authenticate users. Changes are written to config.php and take effect immediately.")},{default:Me(()=>[(M(!0),G(Xe,null,hi(s.value,(f,E)=>(M(),Ke(lv,{key:E,uid:`backend-${E}`,"backend-class":f.class,spec:He(ri)[f.class],values:f.fields,saving:u.value,onUpdate:(h,B)=>l(E,h,B),onRemove:h=>o(E),onSave:d},null,8,["uid","backend-class","spec","values","saving","onUpdate","onRemove"]))),128)),s.value.length===0?(M(),G("div",gv,Le(He(ft)("user_external","No backends configured. Add one below.")),1)):Ie("",!0),le("div",fv,[Ne(He(Qm),{options:He(i),placeholder:He(ft)("user_external","Select backend type to add…"),label:"label",clearable:!1,class:"backends-actions__select","onUpdate:modelValue":n},null,8,["options","placeholder"])])]),_:1},8,["name","description"])]))}}),pv=e0(hv,[["__scopeId","data-v-b2d9a7f8"]]),Ev=Og(pv);Ev.mount("#user-external-admin-settings"); //# sourceMappingURL=user_external-adminSettings.mjs.map diff --git a/lib/IMAP.php b/lib/IMAP.php index 9d2ec94..8767478 100644 --- a/lib/IMAP.php +++ b/lib/IMAP.php @@ -10,6 +10,11 @@ */ namespace OCA\UserExternal; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + /** * User authentication against an IMAP mail server * @@ -26,6 +31,7 @@ class IMAP extends Base { private $domain; private $stripeDomain; private $groupDomain; + private $loginOptions; /** * Create new IMAP authentication provider @@ -36,15 +42,30 @@ class IMAP extends Base { * @param string $domain If provided, loging will be restricted to this domain * @param boolean $stripeDomain (whether to stripe the domain part from the username or not) * @param boolean $groupDomain (whether to add the usere to a group corresponding to the domain of the address) + * @param string|null $loginOptions curl login options selecting the authentication mechanism + * (for example AUTH=PLAIN, AUTH=DIGEST-MD5, AUTH=NTLM, AUTH=GSSAPI or AUTH=*) */ - public function __construct($mailbox, $port = null, $sslmode = null, $domain = null, $stripeDomain = true, $groupDomain = false) { - parent::__construct($mailbox); + public function __construct( + $mailbox, + $port = null, + $sslmode = null, + $domain = null, + $stripeDomain = true, + $groupDomain = false, + $loginOptions = null, + ?IDBConnection $db = null, + ?IUserManager $userManager = null, + ?IGroupManager $groupManager = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($mailbox, $db, $userManager, $groupManager, $logger); $this->mailbox = $mailbox; $this->port = $port === null ? 143 : $port; $this->sslmode = $sslmode; $this->domain = $domain === null ? '' : $domain; $this->stripeDomain = $stripeDomain; $this->groupDomain = $groupDomain; + $this->loginOptions = $loginOptions; } /** @@ -90,22 +111,24 @@ public function checkPassword($uid, $password) { } $protocol = ($this->sslmode === 'ssl') ? 'imaps' : 'imap'; - $url = "{$protocol}://{$this->mailbox}:{$this->port}"; - $ch = curl_init(); + $options = [ + CURLOPT_URL => "{$protocol}://{$this->mailbox}:{$this->port}", + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERPWD => $username . ':' . $password, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_CUSTOMREQUEST => 'CAPABILITY', + ]; if ($this->sslmode === 'tls') { - curl_setopt($ch, CURLOPT_USE_SSL, CURLUSESSL_ALL); + $options[CURLOPT_USE_SSL] = CURLUSESSL_ALL; + } + // The admin UI stores an empty string when no login options are set + if (is_string($this->loginOptions) && $this->loginOptions !== '') { + $options[CURLOPT_LOGIN_OPTIONS] = $this->loginOptions; } - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'CAPABILITY'); - curl_exec($ch); - $errorcode = curl_errno($ch); + $errorcode = $this->executeCurl($options); if ($errorcode === 0) { - curl_close($ch); $uid = mb_strtolower($uid); $this->storeUser($uid, $groups); return $uid; @@ -136,8 +159,20 @@ public function checkPassword($uid, $password) { ); } - curl_close($ch); - return false; } + + /** + * Run the IMAP request + * + * @param array $options curl options + * + * @return int curl error code, 0 on success + */ + protected function executeCurl(array $options): int { + $ch = curl_init(); + curl_setopt_array($ch, $options); + curl_exec($ch); + return curl_errno($ch); + } } diff --git a/src/backendSpecs.ts b/src/backendSpecs.ts index 0b3e45b..0f222c7 100644 --- a/src/backendSpecs.ts +++ b/src/backendSpecs.ts @@ -36,6 +36,7 @@ export const BACKEND_SPECS: Record = { { key: 'domain', label: 'Email domain restriction', type: 'text', default: '' }, { key: 'stripDomain', label: 'Strip domain from username', type: 'checkbox', default: true }, { key: 'groupDomain', label: 'Create group per domain', type: 'checkbox', default: false }, + { key: 'loginOptions', label: 'Login options (e.g. AUTH=DIGEST-MD5)', type: 'text', default: '' }, ], // 993 is uniquely SSL/TLS (imaps); 143 is the plain/STARTTLS port sslPortMap: { '': 143, 'tls': 143, 'ssl': 993 }, diff --git a/tests/unit/IMAPTest.php b/tests/unit/IMAPTest.php new file mode 100644 index 0000000..7cba34c --- /dev/null +++ b/tests/unit/IMAPTest.php @@ -0,0 +1,172 @@ +|null Captured curl options from the last executeCurl() call */ + public ?array $capturedOptions = null; + + protected function executeCurl(array $options): int { + $this->capturedOptions = $options; + return $this->curlErrorCode; + } +} + +class IMAPTest extends TestCase { + private MockObject&IDBConnection $db; + private MockObject&IUserManager $userManager; + private MockObject&IGroupManager $groupManager; + private MockObject&LoggerInterface $logger; + + protected function setUp(): void { + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('escapeLikeParameter')->willReturnArgument(0); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function makeBackend(?string $sslmode = null, ?string $loginOptions = null): TestableIMAP { + return new TestableIMAP( + 'imap.example.com', + 993, + $sslmode, + null, + true, + false, + $loginOptions, + $this->db, + $this->userManager, + $this->groupManager, + $this->logger, + ); + } + + private function mockQueryBuilder(int $existingUserCount = 0): MockObject&IQueryBuilder { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + $expr->method('iLike')->willReturn('1=1'); + + $queryFunction = $this->createMock(IQueryFunction::class); + $funcBuilder = $this->createMock(IFunctionBuilder::class); + $funcBuilder->method('count')->willReturn($queryFunction); + + $countResult = $this->createMock(IResult::class); + $countResult->method('fetchOne')->willReturn($existingUserCount); + $countResult->method('closeCursor')->willReturn(true); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->method('func')->willReturn($funcBuilder); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('insert')->willReturnSelf(); + $qb->method('values')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturnArgument(0); + $qb->method('executeQuery')->willReturn($countResult); + + return $qb; + } + + // ------------------------------------------------------------------------- + // Login options + // ------------------------------------------------------------------------- + + public function testLoginOptionsArePassedToCurl(): void { + $backend = $this->makeBackend(null, 'AUTH=DIGEST-MD5'); + $backend->curlErrorCode = 67; + + $backend->checkPassword('user', 'pass'); + + $this->assertSame('AUTH=DIGEST-MD5', $backend->capturedOptions[CURLOPT_LOGIN_OPTIONS]); + } + + public function testNullLoginOptionsAreNotPassedToCurl(): void { + $backend = $this->makeBackend(null, null); + $backend->curlErrorCode = 67; + + $backend->checkPassword('user', 'pass'); + + $this->assertArrayNotHasKey(CURLOPT_LOGIN_OPTIONS, $backend->capturedOptions); + } + + public function testEmptyLoginOptionsAreNotPassedToCurl(): void { + $backend = $this->makeBackend(null, ''); + $backend->curlErrorCode = 67; + + $backend->checkPassword('user', 'pass'); + + $this->assertArrayNotHasKey(CURLOPT_LOGIN_OPTIONS, $backend->capturedOptions); + } + + // ------------------------------------------------------------------------- + // SSL modes + // ------------------------------------------------------------------------- + + public function testSslModeUsesImapsScheme(): void { + $backend = $this->makeBackend('ssl'); + $backend->curlErrorCode = 67; + + $backend->checkPassword('user', 'pass'); + + $this->assertSame('imaps://imap.example.com:993', $backend->capturedOptions[CURLOPT_URL]); + $this->assertArrayNotHasKey(CURLOPT_USE_SSL, $backend->capturedOptions); + } + + public function testTlsModeRequiresStartTls(): void { + $backend = $this->makeBackend('tls'); + $backend->curlErrorCode = 67; + + $backend->checkPassword('user', 'pass'); + + $this->assertSame('imap://imap.example.com:993', $backend->capturedOptions[CURLOPT_URL]); + $this->assertSame(CURLUSESSL_ALL, $backend->capturedOptions[CURLOPT_USE_SSL]); + } + + // ------------------------------------------------------------------------- + // Result handling + // ------------------------------------------------------------------------- + + public function testSuccessStoresAndReturnsLowercasedUid(): void { + $backend = $this->makeBackend(); + + $qb = $this->mockQueryBuilder(0); // new user + $qb->expects($this->once())->method('executeStatement'); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user', $backend->checkPassword('User', 'pass')); + $this->assertSame('User:pass', $backend->capturedOptions[CURLOPT_USERPWD]); + } + + public function testLoginDeniedReturnsFalseAndLogsError(): void { + $backend = $this->makeBackend(); + $backend->curlErrorCode = 67; // CURLE_LOGIN_DENIED + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('IMAP Login failed')); + + $this->assertFalse($backend->checkPassword('user', 'wrongpass')); + } +}