M1.5 frontend: admin Settings UI + config store + register gating
- config store (public /api/config: site_name, allow_registration, version), loaded in the router guard; site name drives the board header. - session User gains is_admin. - /settings route with requiresAdmin guard + admin-only gear link in the header; SettingsView renders grouped typed fields (text/number/toggle), saves via PATCH /api/settings with saved/error feedback, refreshes public config. - Register link hidden + /register route blocked when registration is closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -8,6 +8,7 @@ const paths: Record<string, string> = {
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||
restore: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -22,6 +23,12 @@ const router = createRouter({
|
||||
component: () => import("../views/BoardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: () => import("../views/SettingsView.vue"),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
@@ -39,13 +46,22 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const session = useSessionStore();
|
||||
// Resolve the current user once, lazily, before the first guarded navigation.
|
||||
const config = useConfigStore();
|
||||
await config.load();
|
||||
if (!session.loaded) {
|
||||
await session.fetchMe();
|
||||
}
|
||||
if (to.meta.requiresAuth && !session.user) {
|
||||
return { name: "login", query: to.fullPath !== "/" ? { redirect: to.fullPath } : undefined };
|
||||
}
|
||||
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
||||
return { name: "board" };
|
||||
}
|
||||
// Registration closed: keep people out of the register screen (the very first
|
||||
// account is still creatable because allow_registration defaults to true).
|
||||
if (to.name === "register" && !config.allowRegistration) {
|
||||
return { name: "login" };
|
||||
}
|
||||
if (to.meta.guestOnly && session.user) {
|
||||
return { name: "board" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface PublicConfig {
|
||||
site_name: string;
|
||||
allow_registration: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// Public, unauthenticated app config (site name, whether signups are open).
|
||||
export const useConfigStore = defineStore("config", () => {
|
||||
const siteName = ref("ThoughtSync");
|
||||
const allowRegistration = ref(true);
|
||||
const version = ref("");
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
try {
|
||||
const cfg = await api.get<PublicConfig>("/api/config");
|
||||
siteName.value = cfg.site_name;
|
||||
allowRegistration.value = cfg.allow_registration;
|
||||
version.value = cfg.version;
|
||||
} catch {
|
||||
// Keep defaults if the config endpoint is unreachable.
|
||||
} finally {
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loaded.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
return { siteName, allowRegistration, version, loaded, load, reload };
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export interface User {
|
||||
email: string;
|
||||
display_name: string;
|
||||
email_verified: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||
import QuickAdd from "../components/QuickAdd.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
@@ -10,6 +11,7 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const notes = useNotesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -69,7 +71,7 @@ async function signOut() {
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-sm font-black text-neutral-900">
|
||||
TS
|
||||
</div>
|
||||
<span class="hidden font-semibold sm:inline">ThoughtSync</span>
|
||||
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex items-center gap-1">
|
||||
@@ -89,6 +91,15 @@ async function signOut() {
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<RouterLink
|
||||
v-if="session.user?.is_admin"
|
||||
to="/settings"
|
||||
class="icon-btn"
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</RouterLink>
|
||||
<span class="hidden text-sm text-neutral-500 sm:inline dark:text-neutral-400">{{
|
||||
session.user?.display_name
|
||||
}}</span>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import type { ApiError } from "../api/client";
|
||||
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -72,7 +74,7 @@ async function submit() {
|
||||
<BaseButton type="submit" :loading="loading">Sign in</BaseButton>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p v-if="config.allowRegistration" class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No account?
|
||||
<RouterLink to="/register" class="font-semibold text-brand-700 hover:underline dark:text-brand"
|
||||
>Create one</RouterLink
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
|
||||
interface SettingItem {
|
||||
key: string;
|
||||
type: "string" | "bool" | "int";
|
||||
value: string | boolean | number;
|
||||
default: string | boolean | number;
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
const items = ref<SettingItem[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const saved = ref(false);
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, SettingItem[]>();
|
||||
for (const it of items.value) {
|
||||
const arr = map.get(it.group) ?? [];
|
||||
arr.push(it);
|
||||
map.set(it.group, arr);
|
||||
}
|
||||
return Array.from(map, ([name, entries]) => ({ name, entries }));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
|
||||
items.value = res.settings;
|
||||
} catch {
|
||||
error.value = "Could not load settings.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = "";
|
||||
saved.value = false;
|
||||
saving.value = true;
|
||||
const payload: Record<string, string | boolean | number> = {};
|
||||
for (const it of items.value) payload[it.key] = it.value;
|
||||
try {
|
||||
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
|
||||
items.value = res.settings;
|
||||
saved.value = true;
|
||||
await config.reload(); // header/site name reflects changes immediately
|
||||
} catch (e) {
|
||||
error.value = (e as { error?: string }).error ?? "Could not save settings.";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Settings</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
|
||||
|
||||
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
|
||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||
|
||||
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
|
||||
it.label
|
||||
}}</label>
|
||||
|
||||
<input
|
||||
v-if="it.type === 'bool'"
|
||||
:id="it.key"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
type="text"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseButton type="submit" :loading="saving">Save changes</BaseButton>
|
||||
<span v-if="saved" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user