Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions logwolf-server/frontend/app/components/nav/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,6 @@ const items = [
url: '/keys',
icon: KeyRound,
},

{
title: 'Settings',
url: '/settings',
icon: Settings,
},
] as const;

type Props = Pick<Route.ComponentProps, 'matches'> & {
Expand All @@ -50,6 +44,18 @@ type Props = Pick<Route.ComponentProps, 'matches'> & {
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 (
<Sidebar>
<SidebarHeader>
Expand All @@ -62,7 +68,7 @@ export function AppSidebar({ matches, projects, currentProject, csrfToken }: Pro

<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
{navItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={matches.some((m) => m?.pathname.includes(item.url))}>
<Link to={item.url}>
Expand Down
67 changes: 67 additions & 0 deletions logwolf-server/frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,6 +45,11 @@ export type Metrics = {
export interface IApi {
getProjects(): Promise<UserProject[]>;
createProject(name: string, slug: string): Promise<Project>;
updateProject(id: string, name: string, slug: string): Promise<Project>;
deleteProject(id: string): Promise<void>;
getMembers(projectId: string): Promise<ProjectMember[]>;
addMember(projectId: string, login: string, role: ProjectRole): Promise<void>;
removeMember(projectId: string, login: string): Promise<void>;
getKeys(projectId: string): Promise<ApiKey[]>;
createKey(projectId: string): Promise<{ key: string; prefix: string; id: string }>;
deleteKey(id: string): Promise<void>;
Expand Down Expand Up @@ -82,6 +96,59 @@ export class Api implements IApi {
return json.data;
}

public async updateProject(id: string, name: string, slug: string): Promise<Project> {
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<Project>;
if (json.error) throw new Error(json.message);

return json.data;
}

public async deleteProject(id: string): Promise<void> {
const res = await fetch(`${this.baseUrl}projects/${id}`, {
method: 'DELETE',
headers: this.internalHeaders(),
});
const json = (await res.json()) as ApiResponse<void>;
if (json.error) throw new Error(json.message);
}

public async getMembers(projectId: string): Promise<ProjectMember[]> {
const res = await fetch(`${this.baseUrl}projects/${projectId}/members`, {
method: 'GET',
headers: this.internalHeaders(),
});
const json = (await res.json()) as ApiResponse<ProjectMember[]>;
if (json.error) throw new Error(json.message);

return json.data ?? [];
}

public async addMember(projectId: string, login: string, role: ProjectRole): Promise<void> {
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<void>;
if (json.error) throw new Error(json.message);
}

public async removeMember(projectId: string, login: string): Promise<void> {
// 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<void>;
if (json.error) throw new Error(json.message);
}

public async getKeys(projectId: string): Promise<ApiKey[]> {
const url = new URL(`${this.baseUrl}keys`);
url.searchParams.set('project_id', projectId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
}
Original file line number Diff line number Diff line change
@@ -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<SettingsActionResult>();

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 (
<Section title='Danger zone'>
<Card className='shadow-none max-w-md border-destructive/50'>
<CardContent className='flex flex-col gap-3'>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>Delete this project</span>
<span className='text-sm text-muted-foreground'>
Its events, API keys and members go with it. This cannot be undone.
</span>
</div>

{fetcher.data?.error && (
<Alert variant='destructive'>
<AlertTitle>{fetcher.data.error}</AlertTitle>
</Alert>
)}

<Button variant='destructive' className='w-fit' onClick={() => onOpenChange(true)}>
<Trash2 />
Delete project
</Button>
</CardContent>
</Card>

<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {project.name}?</DialogTitle>
<DialogDescription>
This deletes the project along with every event, API key and membership in it.
</DialogDescription>
</DialogHeader>

<fetcher.Form method='post'>
<FieldGroup>
<input type='hidden' name='_csrf' value={csrfToken} />
<input type='hidden' name='intent' value='delete' />

<Field>
<FieldLabel htmlFor='confirmation'>
Type <code>{project.name}</code> to confirm
</FieldLabel>

<Input
id='confirmation'
name='confirmation'
type='text'
autoComplete='off'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</Field>

<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>

<Button type='submit' variant='destructive' disabled={!confirmed || fetcher.state !== 'idle'}>
<Trash2 />
Delete project
</Button>
</DialogFooter>
</FieldGroup>
</fetcher.Form>
</DialogContent>
</Dialog>
</Section>
);
}
Original file line number Diff line number Diff line change
@@ -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<SettingsActionResult>();
useSuccessToast(fetcher.data);

return (
<Section title='General'>
<Card className='shadow-none max-w-md'>
<CardContent>
<fetcher.Form method='post'>
<FieldGroup>
{fetcher.data?.error && (
<Alert variant='destructive'>
<AlertTitle>{fetcher.data.error}</AlertTitle>
</Alert>
)}

<input type='hidden' name='_csrf' value={csrfToken} />
<input type='hidden' name='intent' value='rename' />

<Field>
<FieldLabel htmlFor='name'>Name</FieldLabel>

{/* Keyed on the project so opening another project's settings
doesn't leave the previous name in an uncontrolled input. */}
<Input
key={project.id}
id='name'
name='name'
type='text'
defaultValue={project.name}
disabled={!canEdit}
required
/>

<FieldDescription>
Slug: <code>{project.slug}</code> — set when the project was created and fixed after that.
</FieldDescription>
</Field>

{canEdit ? (
<Field className='flex flex-row justify-end items-end'>
<Button type='submit' disabled={fetcher.state !== 'idle'} className='w-fit'>
<Check />
Save
</Button>
</Field>
) : (
<FieldDescription>Only an owner can rename this project.</FieldDescription>
)}
</FieldGroup>
</fetcher.Form>
</CardContent>
</Card>
</Section>
);
}
Loading
Loading