feat: Authentik OIDC SSO, allow/deny RBAC, admin console & Gitea CI
All checks were successful
build-and-push / build (push) Successful in 15m15s
All checks were successful
build-and-push / build (push) Successful in 15m15s
Authentication & RBAC
- Add confidential OIDC client (Authentik) with /auth/oidc/login +
/auth/oidc/callback: discovery, code exchange, id_token verify (go-oidc),
groups claim → role (Archivum-admin → admin, else user). Sessions carry groups.
- Rework ACL into an allow/deny model (new `effect` column + migration).
db.EffectiveAccess resolves user + all groups over the path and its ancestors:
default deny, explicit deny always beats allow.
- Enforce ACL for ALL non-admin users (not just guest) across list/read/save/
delete/move/create/history/diff/images/upload. Admins bypass.
- Seed built-in Archivum-admin / Archivum-reader groups; login allow-list on
users & groups; public (guest) user access is ACL-configurable.
Admin API & UI
- New GraphQL ops: oidcConfig/updateOidcConfig, group CRUD, membership,
setUserRole/setUserLogin/setGroupLogin, userGroups, loginOptions.
- Rebuilt AdminView: SSO config, user/group management + membership, login
toggles, and an allow/deny access-control matrix per path.
- LoginView: "Sign in with Authentik" + public-user option; OIDC callback route.
Rendering/editor
- Fix bug where inline marks (bold/italic/code/strike/link) were dropped on
TipTap→AsciiDoc save. Add RENDERING_IMPROVEMENTS.md with proposals.
CI / build
- .gitea/workflows/build.yaml: build on the Pi5 runner, push
localhost:5000/archivum:{latest,<sha>}. Add .dockerignore; bump Go image to 1.25.
- Docs: ARCHITECTURE.md, README.md, docs/AUTHENTIK_SETUP.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ const ready = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
theme.init()
|
||||
// Consume an OIDC callback (?/oidc/callback#token=…) before deciding view.
|
||||
app.handleOidcCallback()
|
||||
await app.checkStatus()
|
||||
ready.value = true
|
||||
})
|
||||
|
||||
@@ -240,14 +240,17 @@ class TipTapToAsciidoc {
|
||||
nodes.forEach((t) => {
|
||||
if (t.type === 'text') {
|
||||
let text = t.text || ''
|
||||
// Apply inline marks. Note: the previous implementation built the
|
||||
// marked string but never appended it, then appended the *unmarked*
|
||||
// text — silently dropping bold/italic/code/strike/link on save.
|
||||
if (t.marks) {
|
||||
t.marks.forEach(mark => {
|
||||
t.marks.forEach((mark) => {
|
||||
switch (mark.type) {
|
||||
case 'bold': text = `*${text}*`; break
|
||||
case 'italic': text = `_${text}_`; break
|
||||
case 'strike': text = `[line-through]#${text}#`; break
|
||||
case 'code': text = `\`${text}\``; break
|
||||
case 'link':
|
||||
case 'link': {
|
||||
const url = mark.attrs?.href || ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
text = `${url}[${text}]`
|
||||
@@ -255,17 +258,14 @@ class TipTapToAsciidoc {
|
||||
text = `link:${url}[${text}]`
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
textOut += text
|
||||
} else if (t.type === 'footnote') {
|
||||
textOut += `footnote:[${t.attrs?.content || ''}]`
|
||||
}
|
||||
if (t.type === 'text') {
|
||||
let text = t.text || ''
|
||||
textOut += text
|
||||
}
|
||||
if (t.type === 'hardBreak') {
|
||||
} else if (t.type === 'hardBreak') {
|
||||
textOut += ' +\n'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,6 +24,14 @@ export const router = createRouter({
|
||||
name: 'admin',
|
||||
component: () => import('@/views/AdminView.vue'),
|
||||
},
|
||||
{
|
||||
// OIDC redirect target. The token arrives in the URL fragment and is
|
||||
// consumed by App.vue's onMounted before this renders; kept as an
|
||||
// explicit route so the catch-all redirect does not swallow it.
|
||||
path: '/oidc/callback',
|
||||
name: 'oidc-callback',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
|
||||
@@ -8,6 +8,37 @@ export const useAppStore = defineStore('app', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const username = ref<string | null>(localStorage.getItem('username'))
|
||||
const role = ref<string | null>(localStorage.getItem('role'))
|
||||
// Surfaced on the login page (e.g. after a denied OIDC sign-in).
|
||||
const loginError = ref('')
|
||||
|
||||
// Redirect the browser to the backend OIDC entry point. The backend bounces
|
||||
// to Authentik and, on success, back to /oidc/callback with a token fragment.
|
||||
function loginWithOidc() {
|
||||
window.location.href = '/auth/oidc/login'
|
||||
}
|
||||
|
||||
// Handle the /oidc/callback route: read the token (or error) from the URL
|
||||
// fragment, persist the session, and clean the address bar. Returns true if
|
||||
// this was an OIDC callback navigation.
|
||||
function handleOidcCallback(): boolean {
|
||||
if (window.location.pathname !== '/oidc/callback') return false
|
||||
const raw = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
|
||||
const params = new URLSearchParams(raw)
|
||||
const tok = params.get('token')
|
||||
const err = params.get('error')
|
||||
if (tok) {
|
||||
token.value = tok
|
||||
username.value = params.get('username') || ''
|
||||
role.value = params.get('role') || ''
|
||||
localStorage.setItem('token', tok)
|
||||
localStorage.setItem('username', username.value || '')
|
||||
localStorage.setItem('role', role.value || '')
|
||||
} else if (err) {
|
||||
loginError.value = err
|
||||
}
|
||||
window.history.replaceState({}, '', '/')
|
||||
return true
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
@@ -95,6 +126,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
localStorage.removeItem('role')
|
||||
}
|
||||
|
||||
return { requiresSetup, token, username, role, checkStatus, login, loginAsGuest, logout }
|
||||
return {
|
||||
requiresSetup, token, username, role, loginError,
|
||||
checkStatus, login, loginAsGuest, loginWithOidc, handleOidcCallback, logout,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
const app = useAppStore()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const error = ref(app.loginError || '')
|
||||
|
||||
const options = ref({
|
||||
localEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcButtonLabel: 'Sign in with Authentik',
|
||||
publicEnabled: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ loginOptions: typeof options.value }>(
|
||||
`{ loginOptions { localEnabled oidcEnabled oidcButtonLabel publicEnabled } }`,
|
||||
)
|
||||
options.value = data.loginOptions
|
||||
} catch {
|
||||
// fall back to defaults (local + public)
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
@@ -20,23 +39,27 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function continueAsGuest() {
|
||||
async function continueAsPublic() {
|
||||
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'
|
||||
error.value = err instanceof Error ? err.message : 'Failed to continue as public user'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function signInWithOidc() {
|
||||
app.loginWithOidc()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
|
||||
<div class="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
|
||||
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-2 font-bold text-slate-900 dark:text-white text-xl">
|
||||
<svg class="w-8 h-8 text-accent-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -48,7 +71,31 @@ async function continueAsGuest() {
|
||||
|
||||
<h2 class="text-center text-slate-500 dark:text-slate-400">Sign in to continue</h2>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<!-- OIDC / Authentik (primary when enabled) -->
|
||||
<button
|
||||
v-if="options.oidcEnabled"
|
||||
type="button"
|
||||
class="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="signInWithOidc"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ options.oidcButtonLabel }}
|
||||
</button>
|
||||
|
||||
<div v-if="options.oidcEnabled && options.localEnabled" 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 with a local account</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-if="options.localEnabled" @submit.prevent="submit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Username</label>
|
||||
<input
|
||||
@@ -71,10 +118,6 @@ async function continueAsGuest() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg border border-red-200 dark:border-red-500/20">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -88,23 +131,32 @@ async function continueAsGuest() {
|
||||
</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 v-if="error" class="text-sm text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg border border-red-200 dark:border-red-500/20">
|
||||
{{ error }}
|
||||
</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 v-if="options.publicEnabled">
|
||||
<div class="relative mb-4">
|
||||
<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="continueAsPublic"
|
||||
>
|
||||
Continue as public user
|
||||
</button>
|
||||
<p class="text-xs text-slate-400 text-center mt-2">
|
||||
The public user only sees what an administrator has shared.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user