feat: add LDAP support and guest user functionality

- Introduced BaseDN configuration for LDAP in the config.
- Enhanced User model to include IsLDAP field.
- Updated database queries to handle LDAP users.
- Implemented GraphQL queries for LDAP browsing and user authentication type.
- Added ACL management for users and groups, including guest user permissions.
- Updated frontend to support LDAP login and guest user login.
- Improved error handling and user feedback in login and ACL management.
This commit is contained in:
2026-04-15 00:42:40 +02:00
parent 61f33d350a
commit d571f156a6
9 changed files with 710 additions and 93 deletions

View File

@@ -18,10 +18,35 @@ export const useAppStore = defineStore('app', () => {
}
async function login(user: string, password: string) {
const hashed = await hashPassword(user, password)
// Ask the server whether this is a local, LDAP, or guest account.
let authType = 'local'
try {
const at = await gql<{ userAuthType: string }>(
`query AuthType($u: String!) { userAuthType(username: $u) }`,
{ u: user },
)
authType = at.userAuthType
} catch {
// If the query fails just assume local — login will fail anyway.
}
let hashedPwd = ''
let ldapPwd = ''
if (authType === 'guest') {
// Guest needs no password — send empty strings.
} else if (authType === 'ldap') {
// Send plaintext password so the backend can bind against LDAP.
ldapPwd = password
hashedPwd = '' // not used for LDAP
} else {
// Local account: hash the password before sending.
hashedPwd = await hashPassword(user, password)
}
const data = await gql<{ login: string }>(
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
{ u: user, p: hashed },
`mutation Login($u: String!, $p: String!, $lp: String) { login(username: $u, password: $p, ldapPassword: $lp) }`,
{ u: user, p: hashedPwd, lp: ldapPwd || null },
)
token.value = data.login
username.value = user
@@ -29,6 +54,17 @@ export const useAppStore = defineStore('app', () => {
localStorage.setItem('username', user)
}
async function loginAsGuest() {
const data = await gql<{ login: string }>(
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
{ u: 'guest', p: '' },
)
token.value = data.login
username.value = 'guest'
localStorage.setItem('token', data.login)
localStorage.setItem('username', 'guest')
}
async function logout() {
try {
await gql(`mutation { logout }`)
@@ -41,6 +77,6 @@ export const useAppStore = defineStore('app', () => {
localStorage.removeItem('username')
}
return { requiresSetup, token, username, checkStatus, login, logout }
return { requiresSetup, token, username, checkStatus, login, loginAsGuest, logout }
})

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { gql } from '@/lib/gql'
import FolderPicker from '@/components/FolderPicker.vue'
const storagePath = ref('')
const ldapEnabled = ref(false)
const ldapUrl = ref('')
const ldapBaseDN = ref('')
const ldapAdminUser = ref('')
const ldapAdminPassword = ref('')
const ldapPasswordSet = ref(false) // true = server already has a password stored
const oldPass = ref('')
const newPass = ref('')
@@ -25,23 +27,49 @@ const ldapUsers = ref<string[]>([])
const ldapGroups = ref<string[]>([])
const ldapBrowsing = ref(false)
// ACL Management State
type AclSubject = { id: number; name: string; isLdap: boolean }
const aclSubjectUsers = ref<AclSubject[]>([])
const aclSubjectGroups = ref<AclSubject[]>([])
const aclPath = ref('')
const aclSubjectType = ref<'user' | 'group'>('user')
const aclSubjectId = ref<number | null>(null)
const aclPerms = ref({ canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false })
const aclEntries = ref<any[]>([])
onMounted(async () => {
try {
const data = await gql<{ config: any }>(`{ config { storagePath ldap { url adminUser } } }`)
const data = await gql<{ config: any }>(`{ config { storagePath ldap { url baseDN adminUser } } }`)
storagePath.value = data.config.storagePath || ''
if (data.config.ldap) {
ldapEnabled.value = true
ldapUrl.value = data.config.ldap.url
ldapBaseDN.value = data.config.ldap.baseDN || ''
ldapAdminUser.value = data.config.ldap.adminUser
ldapPasswordSet.value = true // server has a password (not returned for security)
}
} catch (err: any) {
status.value = { msg: err.message, isError: true }
} finally {
loading.value = false
}
await loadAclSubjects()
})
async function loadAclSubjects() {
try {
const data = await gql<{ aclSubjects: { users: AclSubject[], groups: AclSubject[] } }>(
`{ aclSubjects { users { id name isLdap } groups { id name isLdap } } }`
)
aclSubjectUsers.value = data.aclSubjects.users
aclSubjectGroups.value = data.aclSubjects.groups
} catch {
// non-fatal
}
}
function showStatus(msg: string, isError: boolean = false) {
status.value = { msg, isError }
setTimeout(() => status.value.msg = '', 4000)
@@ -50,7 +78,6 @@ function showStatus(msg: string, isError: boolean = false) {
async function saveStorage() {
try {
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
// Check if the new storage path has uncommitted files.
const data = await gql<{ repoStatus: { hasUncommitted: boolean } }>(`{ repoStatus { hasUncommitted } }`)
if (data.repoStatus.hasUncommitted) {
showGitPrompt.value = true
@@ -85,12 +112,18 @@ async function saveLdap() {
if (ldapEnabled.value) {
input = {
url: ldapUrl.value,
baseDN: ldapBaseDN.value || null,
adminUser: ldapAdminUser.value,
adminPass: ldapAdminPassword.value
// Only send adminPass if the user typed a new one; empty = keep existing
adminPass: ldapAdminPassword.value || null,
}
}
await gql(`mutation UpdateLdap($i: LDAPInput) { updateLdapConfig(input: $i) }`, { i: input })
if (ldapAdminPassword.value) {
ldapPasswordSet.value = true
ldapAdminPassword.value = ''
}
showStatus(ldapEnabled.value ? 'LDAP config updated.' : 'LDAP disabled.')
} catch (err: any) {
showStatus(err.message, true)
@@ -103,7 +136,7 @@ async function testLdap() {
`mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) {
testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message }
}`,
{ url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value }
{ url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value || null }
)
if (data.testLdapConnection.success) {
showStatus('LDAP connection successful.', false)
@@ -119,14 +152,20 @@ async function browseLdap() {
try {
ldapBrowsing.value = true
const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>(
`query LdapBrowse($url: String, $adminUser: String, $adminPass: String) {
ldapBrowse(url: $url, adminUser: $adminUser, adminPass: $adminPass) { users groups }
`query LdapBrowse($url: String, $baseDN: String, $adminUser: String, $adminPass: String) {
ldapBrowse(url: $url, baseDN: $baseDN, adminUser: $adminUser, adminPass: $adminPass) { users groups }
}`,
{ url: ldapUrl.value || undefined, adminUser: ldapAdminUser.value || undefined, adminPass: ldapAdminPassword.value || undefined }
{
url: ldapUrl.value || undefined,
baseDN: ldapBaseDN.value || undefined,
adminUser: ldapAdminUser.value || undefined,
adminPass: ldapAdminPassword.value || undefined
}
)
ldapUsers.value = data.ldapBrowse.users
ldapGroups.value = data.ldapBrowse.groups
showStatus('LDAP structure loaded.')
await loadAclSubjects()
} catch (err: any) {
showStatus(err.message, true)
} finally {
@@ -138,6 +177,7 @@ async function importSubject(type: 'user' | 'group', name: string) {
try {
await gql(`mutation ImportSubj($type: String!, $name: String!) { importLdapSubject(type: $type, name: $name) }`, { type, name })
showStatus(`Imported ${type} ${name} successfully.`)
await loadAclSubjects()
} catch (err: any) {
showStatus(err.message, true)
}
@@ -158,6 +198,57 @@ async function savePassword() {
showStatus(err.message, true)
}
}
// ── ACL Management ─────────────────────────────────────────────────────────────
const aclSubjects = computed(() =>
aclSubjectType.value === 'user' ? aclSubjectUsers.value : aclSubjectGroups.value
)
async function loadAcl() {
if (!aclPath.value.trim()) { showStatus('Enter a path first.', true); return }
try {
const data = await gql<{ acl: any[] }>(
`query GetAcl($path: String!) { acl(path: $path) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }`,
{ path: aclPath.value.trim() }
)
aclEntries.value = data.acl
} catch (err: any) {
showStatus(err.message, true)
}
}
async function saveAcl() {
if (!aclPath.value.trim() || aclSubjectId.value === null) {
showStatus('Select a path and a subject.', true)
return
}
try {
await gql(
`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
{ input: { path: aclPath.value.trim(), subjectType: aclSubjectType.value, subjectId: aclSubjectId.value, ...aclPerms.value } }
)
showStatus('Permission saved.')
await loadAcl()
} catch (err: any) {
showStatus(err.message, true)
}
}
async function removeAcl(id: number) {
try {
await gql(`mutation RemoveAcl($id: Int!) { removeAcl(id: $id) }`, { id })
aclEntries.value = aclEntries.value.filter(e => e.id !== id)
showStatus('Permission removed.')
} catch (err: any) {
showStatus(err.message, true)
}
}
function subjectName(type: string, id: number): string {
const list = type === 'user' ? aclSubjectUsers.value : aclSubjectGroups.value
return list.find(s => s.id === id)?.name ?? String(id)
}
</script>
<template>
@@ -186,7 +277,7 @@ async function savePassword() {
</div>
</section>
<!-- Git initial commit prompt (shown after storage path change if repo has uncommitted files) -->
<!-- Git initial commit prompt -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
@@ -266,13 +357,20 @@ async function savePassword() {
<label class="label">LDAP URL</label>
<input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" />
</div>
<div class="sm:col-span-2">
<label class="label">Base DN</label>
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" />
<p class="text-xs text-slate-500 mt-1">The root distinguished name to search from. Leave empty to search from the LDAP root (not recommended).</p>
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Admin User (Optional Service Account)</label>
<label class="label">Service Account DN (Optional)</label>
<input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" />
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Admin Password</label>
<input v-model="ldapAdminPassword" type="password" class="input" />
<label class="label">Service Account Password</label>
<input v-model="ldapAdminPassword" type="password" class="input"
:placeholder="ldapPasswordSet ? '••••••• (leave blank to keep)' : 'Password'" />
<p v-if="ldapPasswordSet" class="text-xs text-slate-500 mt-1">A password is already saved. Only fill this in to change it.</p>
</div>
</div>
@@ -316,5 +414,91 @@ async function savePassword() {
</div>
</section>
<!-- ACL Management -->
<section class="card p-6">
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Access Control (ACL)</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Set file/folder permissions for users and groups. The <strong>guest</strong> user has no access by default you must explicitly grant it here. Paths are document slugs (e.g. <code>docs/intro</code>) or folder names (e.g. <code>docs</code>).
</p>
<!-- Path lookup -->
<div class="flex gap-2 mb-6">
<input v-model="aclPath" class="input flex-1" placeholder="docs/intro or docs" @keydown.enter="loadAcl" />
<button class="btn-secondary" @click="loadAcl">Load ACL</button>
</div>
<!-- Existing entries -->
<div v-if="aclEntries.length" class="mb-6">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Current permissions for <code class="text-accent-600">{{ aclPath }}</code></h3>
<div class="overflow-x-auto">
<table class="w-full text-xs border-collapse">
<thead>
<tr class="text-left text-slate-500">
<th class="pb-1 pr-3">Subject</th>
<th class="pb-1 pr-2">Search</th>
<th class="pb-1 pr-2">View</th>
<th class="pb-1 pr-2">Read</th>
<th class="pb-1 pr-2">Edit</th>
<th class="pb-1 pr-2">Create</th>
<th class="pb-1 pr-2">Delete</th>
<th class="pb-1 pr-2">Move</th>
<th class="pb-1"></th>
</tr>
</thead>
<tbody>
<tr v-for="e in aclEntries" :key="e.id" class="border-t border-slate-100 dark:border-slate-700">
<td class="py-1 pr-3 font-medium">{{ e.subjectType === 'user' ? '👤' : '👥' }} {{ subjectName(e.subjectType, e.subjectId) }}</td>
<td class="py-1 pr-2 text-center">{{ e.canSearch ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canView ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canRead ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canEdit ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canCreate ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canDelete ? '✓' : '' }}</td>
<td class="py-1 pr-2 text-center">{{ e.canMove ? '✓' : '' }}</td>
<td class="py-1">
<button class="text-red-500 hover:text-red-700 text-xs" @click="removeAcl(e.id)">Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add/update entry -->
<div class="border-t border-slate-200 dark:border-slate-700 pt-4">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Add / update permission</h3>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="label">Type</label>
<select v-model="aclSubjectType" class="input" @change="aclSubjectId = null">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div>
<label class="label">Subject</label>
<select v-model="aclSubjectId" class="input">
<option :value="null" disabled> select </option>
<option v-for="s in aclSubjects" :key="s.id" :value="s.id">
{{ s.name }}{{ s.isLdap ? ' (LDAP)' : '' }}
</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="label">Permissions</label>
<div class="flex flex-wrap gap-4 mt-1">
<label v-for="perm in ['canSearch','canView','canRead','canEdit','canCreate','canDelete','canMove']" :key="perm" class="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" v-model="(aclPerms as any)[perm]" class="rounded" />
{{ perm.replace('can', '') }}
</label>
</div>
</div>
<div class="sm:col-span-2 flex justify-end">
<button class="btn-primary" @click="saveAcl">Save Permission</button>
</div>
</div>
</div>
</section>
</div>
</template>

View File

@@ -14,11 +14,19 @@ async function submit() {
try {
await app.login(username.value, password.value)
} catch (err: any) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'Failed to sign in'
}
error.value = err instanceof Error ? err.message : 'Failed to sign in'
} finally {
loading.value = false
}
}
async function continueAsGuest() {
error.value = ''
loading.value = true
try {
await app.loginAsGuest()
} catch (err: any) {
error.value = err instanceof Error ? err.message : 'Failed to sign in as guest'
} finally {
loading.value = false
}
@@ -79,6 +87,24 @@ async function submit() {
{{ loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-slate-800 px-2 text-slate-400">or</span>
</div>
</div>
<button
type="button"
class="w-full flex justify-center py-2.5 px-4 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="continueAsGuest"
>
Continue as Guest
</button>
</div>
</div>
</template>