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:
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user