M1.5 frontend: admin Settings UI + config store + register gating
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 31s

- 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:
2026-07-19 18:28:56 -04:00
co-authored by Claude Opus 4.8
parent b46bda38ee
commit 68f7b260de
7 changed files with 208 additions and 3 deletions
+1
View File
@@ -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>
+17 -1
View File
@@ -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" };
}
+38
View File
@@ -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 };
});
+1
View File
@@ -7,6 +7,7 @@ export interface User {
email: string;
display_name: string;
email_verified: boolean;
is_admin: boolean;
}
export const useSessionStore = defineStore("session", () => {
+12 -1
View File
@@ -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>
+3 -1
View File
@@ -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
+136
View File
@@ -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>