From 05c0422c5f6eddbc709fdbf8210828958efd183f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Fri, 21 Aug 2026 22:13:46 -0300 Subject: [PATCH] feat(#16): project settings page --- CLAUDE.md | 4 +- .../app/components/nav/app-sidebar.tsx | 20 ++- logwolf-server/frontend/app/lib/api.ts | 67 +++++++ .../pages/projects/settings/action-result.ts | 15 ++ .../settings/components/danger-zone.tsx | 111 ++++++++++++ .../settings/components/general-section.tsx | 73 ++++++++ .../settings/components/members-section.tsx | 160 +++++++++++++++++ .../settings/components/retention-section.tsx | 95 ++++++++++ .../app/pages/projects/settings/index.tsx | 139 +++++++++++++++ .../frontend/app/pages/settings/index.tsx | 164 ++---------------- logwolf-server/frontend/app/routes.ts | 1 + logwolf-server/frontend/docs/OVERVIEW.md | 42 +++-- 12 files changed, 716 insertions(+), 175 deletions(-) create mode 100644 logwolf-server/frontend/app/pages/projects/settings/action-result.ts create mode 100644 logwolf-server/frontend/app/pages/projects/settings/components/danger-zone.tsx create mode 100644 logwolf-server/frontend/app/pages/projects/settings/components/general-section.tsx create mode 100644 logwolf-server/frontend/app/pages/projects/settings/components/members-section.tsx create mode 100644 logwolf-server/frontend/app/pages/projects/settings/components/retention-section.tsx create mode 100644 logwolf-server/frontend/app/pages/projects/settings/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 3254181..4f9a74b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,10 +134,12 @@ Key files: `lib/client.ts` (Logwolf class), `lib/schema.ts` (Zod schemas), `lib/ Key files: `app/root.tsx`, `app/lib/api.ts` (dashboard API client), `app/lib/auth.server.ts`. -Routes: `/` (public), `/auth`, `/dashboard`, `/events`, `/events/create`, `/events/:id`, `/keys`, `/settings`, `/projects`, `/projects/new`, `/projects/switch`. +Routes: `/` (public), `/auth`, `/dashboard`, `/events`, `/events/create`, `/events/:id`, `/keys`, `/projects`, `/projects/new`, `/projects/switch`, `/projects/:id/settings`. `/settings` is a redirect to the current project's settings page. The layout loader keeps `currentProjectID` in the session honest and redirects a user with no projects to `/projects/new`, the only protected page that renders without a current project. +Project name, retention, members and deletion all live on `/projects/:id/settings`. Retention is editable by any member; renaming, member changes and deletion are owner-only, enforced in the broker and mirrored in the route so the UI can explain itself. + `lib/api.ts` → calls Broker internal routes via `X-Internal-Secret`. Never calls public SDK routes. The frontend instruments itself with `@logwolf/client-js` (`lib/logwolf.ts`) for error tracking. diff --git a/logwolf-server/frontend/app/components/nav/app-sidebar.tsx b/logwolf-server/frontend/app/components/nav/app-sidebar.tsx index da4048f..d7848b0 100644 --- a/logwolf-server/frontend/app/components/nav/app-sidebar.tsx +++ b/logwolf-server/frontend/app/components/nav/app-sidebar.tsx @@ -36,12 +36,6 @@ const items = [ url: '/keys', icon: KeyRound, }, - - { - title: 'Settings', - url: '/settings', - icon: Settings, - }, ] as const; type Props = Pick & { @@ -50,6 +44,18 @@ type Props = Pick & { csrfToken: string; }; export function AppSidebar({ matches, projects, currentProject, csrfToken }: Props) { + // Settings live under the project they configure. /settings still forwards + // there, but linking straight at the project keeps the item highlighted once + // the page is open. + const navItems = [ + ...items, + { + title: 'Settings', + url: currentProject ? `/projects/${currentProject.id}/settings` : '/settings', + icon: Settings, + }, + ]; + return ( @@ -62,7 +68,7 @@ export function AppSidebar({ matches, projects, currentProject, csrfToken }: Pro - {items.map((item) => ( + {navItems.map((item) => ( m?.pathname.includes(item.url))}> diff --git a/logwolf-server/frontend/app/lib/api.ts b/logwolf-server/frontend/app/lib/api.ts index e47c632..0dfe9a5 100644 --- a/logwolf-server/frontend/app/lib/api.ts +++ b/logwolf-server/frontend/app/lib/api.ts @@ -21,6 +21,15 @@ export type ProjectRole = 'owner' | 'member'; /** A project together with the role the requesting user holds in it. */ export type UserProject = Project & { role: ProjectRole }; +/** A row of the project_members collection, as returned by the broker. */ +export type ProjectMember = { + id: string; + project_id: string; + github_login: string; + role: ProjectRole; + created_at: string; +}; + export type RetentionDays = 0 | 30 | 60 | 90 | 180 | 365; export type Metrics = { @@ -36,6 +45,11 @@ export type Metrics = { export interface IApi { getProjects(): Promise; createProject(name: string, slug: string): Promise; + updateProject(id: string, name: string, slug: string): Promise; + deleteProject(id: string): Promise; + getMembers(projectId: string): Promise; + addMember(projectId: string, login: string, role: ProjectRole): Promise; + removeMember(projectId: string, login: string): Promise; getKeys(projectId: string): Promise; createKey(projectId: string): Promise<{ key: string; prefix: string; id: string }>; deleteKey(id: string): Promise; @@ -82,6 +96,59 @@ export class Api implements IApi { return json.data; } + public async updateProject(id: string, name: string, slug: string): Promise { + const res = await fetch(`${this.baseUrl}projects/${id}`, { + method: 'PATCH', + headers: this.internalHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name, slug }), + }); + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + + return json.data; + } + + public async deleteProject(id: string): Promise { + const res = await fetch(`${this.baseUrl}projects/${id}`, { + method: 'DELETE', + headers: this.internalHeaders(), + }); + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + } + + public async getMembers(projectId: string): Promise { + const res = await fetch(`${this.baseUrl}projects/${projectId}/members`, { + method: 'GET', + headers: this.internalHeaders(), + }); + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + + return json.data ?? []; + } + + public async addMember(projectId: string, login: string, role: ProjectRole): Promise { + const res = await fetch(`${this.baseUrl}projects/${projectId}/members`, { + method: 'POST', + headers: this.internalHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ login, role }), + }); + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + } + + public async removeMember(projectId: string, login: string): Promise { + // GitHub logins are URL-safe, but the value reaches us from a form field — + // encoding it keeps a hand-crafted login from reshaping the path. + const res = await fetch(`${this.baseUrl}projects/${projectId}/members/${encodeURIComponent(login)}`, { + method: 'DELETE', + headers: this.internalHeaders(), + }); + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + } + public async getKeys(projectId: string): Promise { const url = new URL(`${this.baseUrl}keys`); url.searchParams.set('project_id', projectId); diff --git a/logwolf-server/frontend/app/pages/projects/settings/action-result.ts b/logwolf-server/frontend/app/pages/projects/settings/action-result.ts new file mode 100644 index 0000000..90b7bf3 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/action-result.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { toast } from 'sonner'; + +/** + * Every intent on the project settings action answers with the same shape, so + * each section can type its own fetcher without importing the route module. + */ +export type SettingsActionResult = { error?: string; success?: string } | null; + +/** Announces a settings change once, when the fetcher comes back with one. */ +export function useSuccessToast(result: SettingsActionResult | undefined) { + useEffect(() => { + if (result?.success) toast(result.success); + }, [result]); +} diff --git a/logwolf-server/frontend/app/pages/projects/settings/components/danger-zone.tsx b/logwolf-server/frontend/app/pages/projects/settings/components/danger-zone.tsx new file mode 100644 index 0000000..9d10682 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/components/danger-zone.tsx @@ -0,0 +1,111 @@ +import { Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useFetcher } from 'react-router'; + +import { Alert, AlertTitle } from '~/components/ui/alert'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '~/components/ui/dialog'; +import { Field, FieldGroup, FieldLabel } from '~/components/ui/field'; +import { Input } from '~/components/ui/input'; +import { Section } from '~/components/ui/section'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import type { UserProject } from '~/lib/api'; + +import type { SettingsActionResult } from '../action-result'; + +type Props = { project: UserProject }; + +export function DangerZone({ project }: Props) { + const csrfToken = useCsrfToken(); + const fetcher = useFetcher(); + + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState(''); + + // A successful delete redirects, so there is no success state to report here + // — only the mismatch and whatever the broker refuses. + const confirmed = confirmation === project.name; + + function onOpenChange(next: boolean) { + setOpen(next); + if (!next) setConfirmation(''); + } + + return ( +
+ + +
+ Delete this project + + Its events, API keys and members go with it. This cannot be undone. + +
+ + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + +
+
+ + + + + Delete {project.name}? + + This deletes the project along with every event, API key and membership in it. + + + + + + + + + + + Type {project.name} to confirm + + + setConfirmation(e.target.value)} + /> + + + + + + + + + + + +
+ ); +} diff --git a/logwolf-server/frontend/app/pages/projects/settings/components/general-section.tsx b/logwolf-server/frontend/app/pages/projects/settings/components/general-section.tsx new file mode 100644 index 0000000..54fa864 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/components/general-section.tsx @@ -0,0 +1,73 @@ +import { Check } from 'lucide-react'; +import { useFetcher } from 'react-router'; + +import { Alert, AlertTitle } from '~/components/ui/alert'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Field, FieldDescription, FieldGroup, FieldLabel } from '~/components/ui/field'; +import { Input } from '~/components/ui/input'; +import { Section } from '~/components/ui/section'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import type { UserProject } from '~/lib/api'; + +import { type SettingsActionResult, useSuccessToast } from '../action-result'; + +type Props = { project: UserProject; canEdit: boolean }; + +export function GeneralSection({ project, canEdit }: Props) { + const csrfToken = useCsrfToken(); + const fetcher = useFetcher(); + useSuccessToast(fetcher.data); + + return ( +
+ + + + + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + + + + + Name + + {/* Keyed on the project so opening another project's settings + doesn't leave the previous name in an uncontrolled input. */} + + + + Slug: {project.slug} — set when the project was created and fixed after that. + + + + {canEdit ? ( + + + + ) : ( + Only an owner can rename this project. + )} + + + + +
+ ); +} diff --git a/logwolf-server/frontend/app/pages/projects/settings/components/members-section.tsx b/logwolf-server/frontend/app/pages/projects/settings/components/members-section.tsx new file mode 100644 index 0000000..e81b4fe --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/components/members-section.tsx @@ -0,0 +1,160 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { useFetcher } from 'react-router'; + +import { Alert, AlertTitle } from '~/components/ui/alert'; +import { Badge } from '~/components/ui/badge'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Field, FieldGroup, FieldLabel } from '~/components/ui/field'; +import { Input } from '~/components/ui/input'; +import { Section } from '~/components/ui/section'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import type { ProjectMember } from '~/lib/api'; + +import { type SettingsActionResult, useSuccessToast } from '../action-result'; + +type Props = { members: ProjectMember[]; currentUser: string; canManage: boolean }; + +export function MembersSection({ members, currentUser, canManage }: Props) { + const csrfToken = useCsrfToken(); + + // Adding and removing get their own fetchers so a pending removal doesn't + // grey out the add form, and each reports its own error where it happened. + const addFetcher = useFetcher(); + const removeFetcher = useFetcher(); + + useSuccessToast(addFetcher.data); + useSuccessToast(removeFetcher.data); + + const addFormRef = useRef(null); + + // The fetcher revalidates the table on its own, but the login it just added + // would otherwise stay in the box waiting to be added a second time. + useEffect(() => { + if (addFetcher.data?.success) addFormRef.current?.reset(); + }, [addFetcher.data]); + + // A project must always keep one owner, so the last one has no Remove button. + // The broker refuses the call too; this only saves the round trip. + const ownerCount = members.filter((m) => m.role === 'owner').length; + const removing = removeFetcher.formData?.get('login')?.toString(); + + return ( +
+
+ {(addFetcher.data?.error || removeFetcher.data?.error) && ( + + {addFetcher.data?.error ?? removeFetcher.data?.error} + + )} + + + + + + + Member + Role + Joined + {canManage && } + + + + + {members.map((member) => { + const isLastOwner = member.role === 'owner' && ownerCount === 1; + + return ( + + + {member.github_login} + {member.github_login === currentUser && ( + (you) + )} + + + + {member.role} + + + + {new Date(member.created_at).toLocaleDateString()} + + + {canManage && ( + + {isLastOwner ? ( + Last owner + ) : ( + + + + + + + + )} + + )} + + ); + })} + +
+
+
+ + {canManage && ( + + + + + + + +
+ + GitHub login + + + + + Role + + + + + +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/logwolf-server/frontend/app/pages/projects/settings/components/retention-section.tsx b/logwolf-server/frontend/app/pages/projects/settings/components/retention-section.tsx new file mode 100644 index 0000000..80276b2 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/components/retention-section.tsx @@ -0,0 +1,95 @@ +import { Check } from 'lucide-react'; +import { useFetcher } from 'react-router'; + +import { Alert, AlertTitle } from '~/components/ui/alert'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Field, FieldDescription, FieldGroup, FieldLabel } from '~/components/ui/field'; +import { Section } from '~/components/ui/section'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '~/components/ui/select'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import type { RetentionDays } from '~/lib/api'; + +import { type SettingsActionResult, useSuccessToast } from '../action-result'; + +type RetentionDaysMap = { + [P in T as `${P}`]: string; +}; + +const retentionDaysMap: RetentionDaysMap = { + 0: 'Forever', + 30: '30 days', + 60: '60 days', + 90: '90 days', + 180: '180 days', + 365: '365 days', +}; + +const retentionOptions = Object.entries(retentionDaysMap); + +type Props = { days: RetentionDays }; + +export function RetentionSection({ days }: Props) { + const csrfToken = useCsrfToken(); + const fetcher = useFetcher(); + useSuccessToast(fetcher.data); + + return ( +
+ + + + + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + + + + + Retention time + + + + Events older than this are dropped from this project. + + + + + + + + + +
+ ); +} diff --git a/logwolf-server/frontend/app/pages/projects/settings/index.tsx b/logwolf-server/frontend/app/pages/projects/settings/index.tsx new file mode 100644 index 0000000..c81470d --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/settings/index.tsx @@ -0,0 +1,139 @@ +import { redirect } from 'react-router'; + +import { Page } from '~/components/nav/page'; +import { eventContext } from '~/context'; +import { createApi } from '~/lib/api'; +import { requireAuth } from '~/lib/auth.server'; +import { validateCsrfToken } from '~/lib/csrf.server'; +import { commitSession, getSession } from '~/lib/session.server'; + +import type { Route } from './+types'; +import { DangerZone } from './components/danger-zone'; +import { GeneralSection } from './components/general-section'; +import { MembersSection } from './components/members-section'; +import { RetentionSection } from './components/retention-section'; + +export function meta({ data }: Route.MetaArgs) { + return [{ title: `${data?.project.name ?? 'Project'} settings - Logwolf` }]; +} + +/** + * Resolves the project in the URL against the projects the caller belongs to. + * One list call answers both "may they open this page?" and "what role do they + * hold?" — the broker would reject a non-member anyway, but as a 403 that only + * the error boundary could render. + */ +async function requireProject(request: Request, id: string | undefined) { + const user = await requireAuth(request); + const api = createApi(user.login); + + const projects = await api.getProjects(); + const project = projects.find((p) => p.id === id); + if (!project) throw redirect('/projects'); + + return { user, api, project }; +} + +export async function loader({ request, params, context }: Route.LoaderArgs) { + const event = context.get(eventContext); + event?.addTag('loader'); + + const { user, api, project } = await requireProject(request, params.id); + + const [members, retention] = await Promise.all([api.getMembers(project.id), api.getRetention(project.id)]); + event?.set('loaderData', { project, memberCount: members.length, days: retention.days }); + + return { project, members, days: retention.days, currentUser: user.login }; +} + +export async function action({ request, params, context }: Route.ActionArgs) { + const event = context.get(eventContext); + event?.addTag('action'); + + const { api, project } = await requireProject(request, params.id); + + const fd = await request.formData(); + await validateCsrfToken(request, fd); + + const intent = fd.get('intent')?.toString() ?? ''; + event?.set('intent', intent); + + // Retention is the one setting any member may change; the broker enforces + // the same split, but repeating it here turns a bare "forbidden" from a + // stale tab into a message the page can show next to the control. + if (intent !== 'retention' && project.role !== 'owner') { + return { error: 'Only an owner can change this.' }; + } + + try { + if (intent === 'rename') { + const name = fd.get('name')?.toString().trim() ?? ''; + if (!name) return { error: 'Name is required.' }; + + // The slug is fixed at creation, so the stored one goes back unchanged — + // the broker rejects an update that carries no valid slug. + await api.updateProject(project.id, name, project.slug); + return { success: `Renamed to ${name}.` }; + } + + if (intent === 'retention') { + const days = Number(fd.get('days')); + const res = await api.updateRetention(project.id, days); + event?.set('actionData', res); + return { success: 'Retention updated.' }; + } + + if (intent === 'add-member') { + const login = fd.get('login')?.toString().trim() ?? ''; + const role = fd.get('role')?.toString() === 'owner' ? 'owner' : 'member'; + if (!login) return { error: 'A GitHub login is required.' }; + + await api.addMember(project.id, login, role); + return { success: `Added ${login} as ${role}.` }; + } + + if (intent === 'remove-member') { + const login = fd.get('login')?.toString() ?? ''; + await api.removeMember(project.id, login); + return { success: `Removed ${login}.` }; + } + + if (intent === 'delete') { + // The dialog disables its button until the typed name matches, but the + // check that counts is this one — a form post never sees that button. + const confirmation = fd.get('confirmation')?.toString() ?? ''; + if (confirmation !== project.name) return { error: 'The project name does not match.' }; + + await api.deleteProject(project.id); + + const session = await getSession(request.headers.get('Cookie')); + if (session.get('currentProjectID') === project.id) session.unset('currentProjectID'); + + // /projects renders inside the layout, which forwards to /projects/new + // when the project just deleted was the caller's last one. + return redirect('/projects', { headers: { 'Set-Cookie': await commitSession(session) } }); + } + + return null; + } catch (err) { + event?.setSeverity('error'); + event?.set('actionError', err); + return { error: (err as Error).message }; + } +} + +export default function ProjectSettings({ loaderData }: Route.ComponentProps) { + const { project, members, days, currentUser } = loaderData; + const isOwner = project.role === 'owner'; + + return ( + +
+ + + + {isOwner && } +
+
+ ); +} diff --git a/logwolf-server/frontend/app/pages/settings/index.tsx b/logwolf-server/frontend/app/pages/settings/index.tsx index af74da7..135908c 100644 --- a/logwolf-server/frontend/app/pages/settings/index.tsx +++ b/logwolf-server/frontend/app/pages/settings/index.tsx @@ -1,166 +1,24 @@ -import { Check } from 'lucide-react'; -import { useFetcher } from 'react-router'; -import { toast } from 'sonner'; +import { redirect } from 'react-router'; -import { Page } from '~/components/nav/page'; -import { Alert, AlertTitle } from '~/components/ui/alert'; -import { Button } from '~/components/ui/button'; -import { Card, CardContent } from '~/components/ui/card'; -import { Field, FieldGroup, FieldLabel } from '~/components/ui/field'; -import { Section } from '~/components/ui/section'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; import { eventContext } from '~/context'; -import { useCsrfToken } from '~/hooks/use-csrf-token'; -import { createApi, type RetentionDays } from '~/lib/api'; import { requireAuth } from '~/lib/auth.server'; -import { validateCsrfToken } from '~/lib/csrf.server'; +import { getCurrentProjectID } from '~/lib/session.server'; import type { Route } from './+types'; -type RetentionDaysMap = { - [P in T as `${P}`]: string; -}; - -const retentionDaysMap: RetentionDaysMap = { - 0: 'Forever', - 30: '30 days', - 60: '60 days', - 90: '90 days', - 180: '180 days', - 365: '365 days', -}; - +/** + * Settings moved under the project they configure. This path stays behind as a + * forward so bookmarks — and anything still linking to /settings — land on the + * settings of whichever project the session is currently pointed at. + */ export async function loader({ request, context }: Route.LoaderArgs) { const event = context.get(eventContext); event?.addTag('loader'); - const user = await requireAuth(request); - const projectId = new URL(request.url).searchParams.get('projectId') ?? ''; - - if (!projectId) { - return { days: 90 as RetentionDays, projectId: '', noProject: true }; - } - - const api = createApi(user.login); - const res = await api.getRetention(projectId); - event?.set('loaderData', res); - - return { ...res, projectId, noProject: false }; -} - -export async function action({ request, context }: Route.ActionArgs) { - const event = context.get(eventContext); - event?.addTag('action'); - - try { - const user = await requireAuth(request); - const fd = await request.formData(); - - await validateCsrfToken(request, fd); - - const intent = fd.get('intent'); - event?.set('intent', intent); - - if (intent === 'update') { - const days = fd.get('days'); - const projectId = fd.get('projectId')?.toString() ?? ''; - const api = createApi(user.login); - const res = await api.updateRetention(projectId, +days!); - event?.set('actionData', res); - return { data: res }; - } - - return null; - } catch (err) { - event?.setSeverity('error'); - event?.set('actionError', err); - return { error: err as Error }; - } -} - -export function meta() { - return [{ title: 'Settings - Logwolf' }]; -} - -type FetcherData = Awaited>; - -export default function Settings({ loaderData }: Route.ComponentProps) { - const csrfToken = useCsrfToken(); - const fetcher = useFetcher(); - const actionData = fetcher.data; - - if (actionData?.data) toast('Updated retention days: ' + retentionDaysMap[actionData.data.days]); - - if (loaderData.noProject) { - return ( - -

Select a project to manage its settings.

-
- ); - } - - return ( - -
-
-
- - - - - {actionData?.error && ( - - {actionData.error.message} - - )} - - - - - - - Retention time - - - + const projectId = await getCurrentProjectID(request); + if (!projectId) return redirect('/projects/new'); - - - - - - - -
-
-
-
- ); + return redirect(`/projects/${projectId}/settings`); } diff --git a/logwolf-server/frontend/app/routes.ts b/logwolf-server/frontend/app/routes.ts index 54a6131..9ebbb9e 100644 --- a/logwolf-server/frontend/app/routes.ts +++ b/logwolf-server/frontend/app/routes.ts @@ -15,5 +15,6 @@ export default [ route('settings', 'pages/settings/index.tsx'), route('projects', 'pages/projects/index.tsx'), route('projects/new', 'pages/projects/new/index.tsx'), + route('projects/:id/settings', 'pages/projects/settings/index.tsx'), ]), ] satisfies RouteConfig; diff --git a/logwolf-server/frontend/docs/OVERVIEW.md b/logwolf-server/frontend/docs/OVERVIEW.md index e23b367..f478ca1 100644 --- a/logwolf-server/frontend/docs/OVERVIEW.md +++ b/logwolf-server/frontend/docs/OVERVIEW.md @@ -34,7 +34,8 @@ app/ │ ├── dashboard/ # Metrics overview + charts │ ├── events/ # Event list, detail view, create form │ ├── keys/ # API key management -│ └── settings/ # System settings (retention TTL) +│ ├── projects/ # Project list, create, switch, per-project settings +│ └── settings/ # Redirect to the current project's settings ├── lib/ │ ├── api.ts # Dashboard API client (calls Broker internal routes) │ ├── logwolf.ts # Logwolf SDK setup for client-side error tracking @@ -55,19 +56,20 @@ app/ ## Routes -| Path | Auth | Description | -| ------------------ | --------- | ----------------------------- | -| `/` | Public | Landing page | -| `/auth` | Public | GitHub OAuth login | -| `/dashboard` | Protected | Metrics overview with charts | -| `/events` | Protected | Paginated event list | -| `/events/create` | Protected | Create a new event | -| `/events/:id` | Protected | Event detail view | -| `/keys` | Protected | API key management | -| `/settings` | Protected | Retention and system settings | -| `/projects` | Protected | Projects the user belongs to | -| `/projects/new` | Protected | Create a project | -| `/projects/switch` | Protected | POST-only project switch | +| Path | Auth | Description | +| ------------------------ | --------- | ------------------------------------------- | +| `/` | Public | Landing page | +| `/auth` | Public | GitHub OAuth login | +| `/dashboard` | Protected | Metrics overview with charts | +| `/events` | Protected | Paginated event list | +| `/events/create` | Protected | Create a new event | +| `/events/:id` | Protected | Event detail view | +| `/keys` | Protected | API key management | +| `/settings` | Protected | Redirects to the current project's settings | +| `/projects` | Protected | Projects the user belongs to | +| `/projects/new` | Protected | Create a project | +| `/projects/switch` | Protected | POST-only project switch | +| `/projects/:id/settings` | Protected | Rename, retention, members, delete | ## Project selection @@ -81,6 +83,18 @@ Switching projects is a POST to `/projects/switch`, which re-checks membership server-side before writing the session. The sidebar switcher and the `/projects` list both go through it. +## Project settings + +`/projects/:id/settings` holds everything scoped to one project: its name, the +retention window, the member list, and deletion. Both the loader and the action +resolve the `:id` against the caller's own project list, so a project the user +does not belong to sends them back to `/projects` instead of surfacing a 403. + +Every section except retention is owner-only — the broker enforces that as well, +so the role checks in the route are there to keep a stale tab from producing a +bare "forbidden". Deleting a project clears `currentProjectID` when it was the +one in session and returns to `/projects`, where the layout takes over. + ## Authentication 1. User initiates login via GitHub OAuth 2.0.