diff --git a/CLAUDE.md b/CLAUDE.md index e049249..3254181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,9 @@ 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`. +Routes: `/` (public), `/auth`, `/dashboard`, `/events`, `/events/create`, `/events/:id`, `/keys`, `/settings`, `/projects`, `/projects/new`, `/projects/switch`. + +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. `lib/api.ts` → calls Broker internal routes via `X-Internal-Secret`. Never calls public SDK routes. diff --git a/logwolf-server/broker/cmd/api/handlers.go b/logwolf-server/broker/cmd/api/handlers.go index 9b3812c..afde255 100644 --- a/logwolf-server/broker/cmd/api/handlers.go +++ b/logwolf-server/broker/cmd/api/handlers.go @@ -501,14 +501,14 @@ func (app *Config) ListProjects(w http.ResponseWriter, r *http.Request) { defer client.Close() args := data.RPCUserProjectsArgs{GithubLogin: userLogin} - var projects []data.Project + var projects []data.UserProject if err := client.Call("RPCServer.ListUserProjects", &args, &projects); err != nil { app.errorJSON(w, err) return } if projects == nil { - projects = []data.Project{} + projects = []data.UserProject{} } app.writeJSON(w, http.StatusOK, jsonResponse{Error: false, Message: "OK!", Data: projects}) @@ -544,6 +544,13 @@ func (app *Config) CreateProject(w http.ResponseWriter, r *http.Request) { var project data.Project if err := client.Call("RPCServer.CreateProject", &data.RPCCreateProjectArgs{Name: body.Name, Slug: body.Slug}, &project); err != nil { + // Slugs are globally unique, so a collision is a client mistake, not a + // server fault. net/rpc flattens errors to strings, so the Mongo + // duplicate-key code is the only thing left to match on. + if strings.Contains(err.Error(), "E11000") { + app.errorJSON(w, fmt.Errorf("a project with that slug already exists"), http.StatusConflict) + return + } app.errorJSON(w, err) return } diff --git a/logwolf-server/broker/docs/OVERVIEW.md b/logwolf-server/broker/docs/OVERVIEW.md index 6a06f47..fa5927b 100644 --- a/logwolf-server/broker/docs/OVERVIEW.md +++ b/logwolf-server/broker/docs/OVERVIEW.md @@ -32,14 +32,25 @@ cmd/api/ ### Internal routes (`X-Internal-Secret` header required) -| Method | Path | Description | -| -------- | --------------------- | --------------------- | -| `GET` | `/keys` | List API keys | -| `POST` | `/keys` | Create an API key | -| `DELETE` | `/keys/{id}` | Revoke an API key | -| `GET` | `/settings/retention` | Get retention setting | -| `PATCH` | `/settings/retention` | Update retention TTL | -| `GET` | `/metrics` | Usage analytics | +| Method | Path | Description | +| -------- | -------------------------------- | ------------------------------------------- | +| `GET` | `/keys` | List API keys | +| `POST` | `/keys` | Create an API key | +| `DELETE` | `/keys/{id}` | Revoke an API key | +| `GET` | `/settings/retention` | Get retention setting | +| `PATCH` | `/settings/retention` | Update retention TTL | +| `GET` | `/metrics` | Usage analytics | +| `GET` | `/projects` | Projects the caller belongs to, with `role` | +| `POST` | `/projects` | Create a project (409 if the slug is taken) | +| `GET` | `/projects/{id}` | Get one project | +| `PATCH` | `/projects/{id}` | Rename a project | +| `DELETE` | `/projects/{id}` | Delete a project and everything under it | +| `GET` | `/projects/{id}/members` | List members | +| `POST` | `/projects/{id}/members` | Add a member | +| `DELETE` | `/projects/{id}/members/{login}` | Remove a member | + +Internal routes also require `X-User-Login`; project access is checked against +that login on every call. ### Health diff --git a/logwolf-server/frontend/app/components/nav/project-switcher.tsx b/logwolf-server/frontend/app/components/nav/project-switcher.tsx index 071efef..5bd0d4b 100644 --- a/logwolf-server/frontend/app/components/nav/project-switcher.tsx +++ b/logwolf-server/frontend/app/components/nav/project-switcher.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronsUpDown, Plus } from 'lucide-react'; +import { Check, ChevronsUpDown, LayoutList, Plus } from 'lucide-react'; import { Link, useLocation, useSubmit } from 'react-router'; import type { Project } from '~/lib/api'; @@ -82,6 +82,13 @@ export function ProjectSwitcher({ projects, currentProject, csrfToken }: Props) + + + + All projects + + + diff --git a/logwolf-server/frontend/app/hooks/use-projects.ts b/logwolf-server/frontend/app/hooks/use-projects.ts new file mode 100644 index 0000000..63516f2 --- /dev/null +++ b/logwolf-server/frontend/app/hooks/use-projects.ts @@ -0,0 +1,17 @@ +import { useRouteLoaderData } from 'react-router'; + +import type { loader as layoutLoader } from '../pages/layout'; + +/** + * Projects the signed-in user belongs to, plus the one they are working in. + * Both are already loaded by the layout every dashboard page renders inside, + * so pages read them from there instead of asking the broker again. + */ +export function useProjects() { + const layoutData = useRouteLoaderData('pages/layout'); + + return { + projects: layoutData?.projects ?? [], + currentProject: layoutData?.currentProject, + }; +} diff --git a/logwolf-server/frontend/app/lib/api.ts b/logwolf-server/frontend/app/lib/api.ts index 06d9e78..e47c632 100644 --- a/logwolf-server/frontend/app/lib/api.ts +++ b/logwolf-server/frontend/app/lib/api.ts @@ -16,6 +16,11 @@ export type Project = { created_at: string; }; +export type ProjectRole = 'owner' | 'member'; + +/** A project together with the role the requesting user holds in it. */ +export type UserProject = Project & { role: ProjectRole }; + export type RetentionDays = 0 | 30 | 60 | 90 | 180 | 365; export type Metrics = { @@ -29,7 +34,8 @@ export type Metrics = { }; export interface IApi { - getProjects(): Promise; + getProjects(): Promise; + createProject(name: string, slug: string): Promise; getKeys(projectId: string): Promise; createKey(projectId: string): Promise<{ key: string; prefix: string; id: string }>; deleteKey(id: string): Promise; @@ -53,12 +59,24 @@ export class Api implements IApi { }; } - public async getProjects(): Promise { + public async getProjects(): Promise { const res = await fetch(`${this.baseUrl}projects`, { method: 'GET', headers: this.internalHeaders(), }); - const json = (await res.json()) as ApiResponse; + const json = (await res.json()) as ApiResponse; + if (json.error) throw new Error(json.message); + + return json.data; + } + + public async createProject(name: string, slug: string): Promise { + const res = await fetch(`${this.baseUrl}projects`, { + method: 'POST', + 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; diff --git a/logwolf-server/frontend/app/lib/slug.ts b/logwolf-server/frontend/app/lib/slug.ts new file mode 100644 index 0000000..1704e8c --- /dev/null +++ b/logwolf-server/frontend/app/lib/slug.ts @@ -0,0 +1,20 @@ +/** + * Turns a free-text project name into a URL-safe slug. + * + * The output must satisfy the broker's `data.ValidSlug` regex, + * `^[a-z0-9]+(?:-[a-z0-9]+)*$`, because whatever this returns is what gets + * submitted. A name made only of punctuation slugifies to an empty string, + * which the caller has to reject before hitting the API. + */ +export function slugify(name: string): string { + return ( + name + .normalize('NFKD') + // NFKD splits accented letters into base + combining mark; dropping the + // marks keeps the base letters instead of losing the whole character. + .replaceAll(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + ); +} diff --git a/logwolf-server/frontend/app/pages/layout.tsx b/logwolf-server/frontend/app/pages/layout.tsx index 2c475f5..dd6da77 100644 --- a/logwolf-server/frontend/app/pages/layout.tsx +++ b/logwolf-server/frontend/app/pages/layout.tsx @@ -23,6 +23,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const api = createApi(user.login); const projects = await api.getProjects(); + const url = new URL(request.url); // The stored project is only usable while the user is still a member of it — // a deleted project, or one the user was removed from, falls back to the @@ -36,12 +37,18 @@ export async function loader({ request, context }: Route.LoaderArgs) { // Child loaders of this request already read the stale cookie, so reload // the same URL to let them run against the corrected session. - const url = new URL(request.url); throw redirect(`${url.pathname}${url.search}`, { headers: { 'Set-Cookie': await commitSession(session) }, }); } + // Every page under this layout is scoped to a project, so a user who has + // none has nothing to render. Creating one is the only way forward, and that + // page is the one place reachable without a project in session. + if (projects.length === 0 && url.pathname !== '/projects/new') { + throw redirect('/projects/new'); + } + event?.set('currentProjectID', currentProject?.id ?? null); return data( diff --git a/logwolf-server/frontend/app/pages/projects/index.tsx b/logwolf-server/frontend/app/pages/projects/index.tsx new file mode 100644 index 0000000..40ef3a1 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/index.tsx @@ -0,0 +1,74 @@ +import { Check, Plus } from 'lucide-react'; +import { Link, useSubmit } from 'react-router'; + +import { Page } from '~/components/nav/page'; +import { Badge } from '~/components/ui/badge'; +import { Button } from '~/components/ui/button'; +import { Card, CardContent } from '~/components/ui/card'; +import { Section } from '~/components/ui/section'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import { useProjects } from '~/hooks/use-projects'; + +export function meta() { + return [{ title: 'Projects - Logwolf' }]; +} + +export default function Projects() { + const submit = useSubmit(); + const csrfToken = useCsrfToken(); + const { projects, currentProject } = useProjects(); + + function open(projectId: string) { + // Reuses the switcher action so the session write and the membership + // re-check that guards it stay in one place. + submit({ projectId, redirectTo: '/dashboard', _csrf: csrfToken }, { method: 'POST', action: '/projects/switch' }); + } + + return ( + +
+ + + New project + + + } + > +
+ {projects.map((project) => ( + + + + + + ))} +
+
+
+ ); +} diff --git a/logwolf-server/frontend/app/pages/projects/new/index.tsx b/logwolf-server/frontend/app/pages/projects/new/index.tsx new file mode 100644 index 0000000..93b3324 --- /dev/null +++ b/logwolf-server/frontend/app/pages/projects/new/index.tsx @@ -0,0 +1,132 @@ +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { redirect, useFetcher } 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, FieldDescription, FieldGroup, FieldLabel } from '~/components/ui/field'; +import { Input } from '~/components/ui/input'; +import { Section } from '~/components/ui/section'; +import { eventContext } from '~/context'; +import { useCsrfToken } from '~/hooks/use-csrf-token'; +import { useProjects } from '~/hooks/use-projects'; +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 { slugify } from '~/lib/slug'; + +import type { Route } from './+types'; + +export function meta() { + return [{ title: 'New Project - Logwolf' }]; +} + +export async function action({ request, context }: Route.ActionArgs) { + const event = context.get(eventContext); + event?.addTag('action'); + + const user = await requireAuth(request); + const fd = await request.formData(); + + await validateCsrfToken(request, fd); + + const name = fd.get('name')?.toString().trim() ?? ''; + // The slug is derived here, not read off the form: what the user saw while + // typing is only a preview, and the browser doesn't get to pick the slug. + const slug = slugify(name); + event?.set('slug', slug); + + if (!name) return { error: 'Name is required.' }; + if (!slug) return { error: 'Name must contain at least one letter or number.' }; + + try { + const api = createApi(user.login); + const project = await api.createProject(name, slug); + event?.set('actionData', project); + + // A project you just created is the one you want to be looking at. + const session = await getSession(request.headers.get('Cookie')); + session.set('currentProjectID', project.id); + + return redirect('/dashboard', { headers: { 'Set-Cookie': await commitSession(session) } }); + } catch (err) { + event?.setSeverity('error'); + event?.set('actionError', err); + return { error: (err as Error).message }; + } +} + +export default function NewProject() { + const fetcher = useFetcher(); + const csrfToken = useCsrfToken(); + const { projects } = useProjects(); + + const [name, setName] = useState(''); + const slug = slugify(name); + + // Anyone without a project was sent here by the layout loader rather than + // arriving on purpose, so the page explains itself the first time around. + const isFirstProject = projects.length === 0; + + return ( + +
+ + + + + {isFirstProject && ( +

+ Projects keep events, API keys and retention settings separate. Create one to get started. +

+ )} + + {fetcher.data?.error && ( + + {fetcher.data.error} + + )} + + + + + Name + + setName(e.target.value)} + required + /> + + + {slug ? ( + <> + Slug: {slug} + + ) : ( + 'The slug is generated from the name.' + )} + + + + + + +
+
+
+
+
+
+ ); +} diff --git a/logwolf-server/frontend/app/routes.ts b/logwolf-server/frontend/app/routes.ts index 6eab5bf..54a6131 100644 --- a/logwolf-server/frontend/app/routes.ts +++ b/logwolf-server/frontend/app/routes.ts @@ -13,5 +13,7 @@ export default [ route('events/new', 'pages/events/create/index.tsx'), route('keys', 'pages/keys/index.tsx'), route('settings', 'pages/settings/index.tsx'), + route('projects', 'pages/projects/index.tsx'), + route('projects/new', 'pages/projects/new/index.tsx'), ]), ] satisfies RouteConfig; diff --git a/logwolf-server/frontend/docs/OVERVIEW.md b/logwolf-server/frontend/docs/OVERVIEW.md index 15b186e..e23b367 100644 --- a/logwolf-server/frontend/docs/OVERVIEW.md +++ b/logwolf-server/frontend/docs/OVERVIEW.md @@ -43,9 +43,11 @@ app/ │ ├── csrf.server.ts # CSRF token generation + validation │ ├── format.ts # Formatting utilities (dates, numbers) │ ├── parse.ts # Parsing utilities +│ ├── slug.ts # Project name -> URL-safe slug │ └── utils.ts # General utilities ├── hooks/ │ ├── use-csrf-token.ts # Fetch CSRF token for form submissions +│ ├── use-projects.ts # Read the user's projects from the layout loader │ └── use-mobile.ts # Detect mobile viewport └── store/ └── theme-provider.tsx # Dark/light mode provider (next-themes) @@ -53,16 +55,31 @@ 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 | +| 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 | + +## Project selection + +Every protected page except `/projects/new` is scoped to one project, held in the +session as `currentProjectID`. The layout loader owns that value: it re-points the +session when the stored project is gone or the user lost access to it, and it sends +a user with no projects at all to `/projects/new` — the one page that renders +without a current project. + +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. ## Authentication diff --git a/logwolf-server/integration/project_crud_test.go b/logwolf-server/integration/project_crud_test.go index 89c8c79..40f218f 100644 --- a/logwolf-server/integration/project_crud_test.go +++ b/logwolf-server/integration/project_crud_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "github.com/testcontainers/testcontainers-go" @@ -209,6 +210,24 @@ func TestDeleteProject_Cascade(t *testing.T) { } } +// The broker turns a slug collision into a 409 by matching "E11000" in the RPC +// error string, so that substring is part of the contract this test pins down. +func TestInsertProject_DuplicateSlug(t *testing.T) { + m := setupProjectModels(t) + + if _, err := m.InsertProject(data.Project{Name: "First", Slug: "taken"}); err != nil { + t.Fatalf("first InsertProject: %v", err) + } + + _, err := m.InsertProject(data.Project{Name: "Second", Slug: "taken"}) + if err == nil { + t.Fatal("second InsertProject: expected duplicate key error, got nil") + } + if !strings.Contains(err.Error(), "E11000") { + t.Errorf("second InsertProject: error %q must mention E11000", err) + } +} + // --- Member helpers --- func TestInsertProjectMember_Duplicate(t *testing.T) { @@ -306,6 +325,19 @@ func TestGetProjectsForUser(t *testing.T) { if len(projects) != 2 { t.Errorf("GetProjectsForUser: want 2 projects, got %d", len(projects)) } + + // The role travels with the project so the dashboard can label each one + // without a second round trip per project. + roles := map[string]string{} + for _, p := range projects { + roles[p.Slug] = p.Role + } + if roles["p1"] != data.RoleOwner { + t.Errorf("GetProjectsForUser: p1 role = %q, want %q", roles["p1"], data.RoleOwner) + } + if roles["p2"] != data.RoleMember { + t.Errorf("GetProjectsForUser: p2 role = %q, want %q", roles["p2"], data.RoleMember) + } } func TestGetProjectsForUser_NoMemberships(t *testing.T) { diff --git a/logwolf-server/logger/cmd/api/rpc.go b/logwolf-server/logger/cmd/api/rpc.go index 633216a..31cafa6 100644 --- a/logwolf-server/logger/cmd/api/rpc.go +++ b/logwolf-server/logger/cmd/api/rpc.go @@ -157,7 +157,7 @@ func (r *RPCServer) DeleteProject(args *data.RPCProjectIDArgs, reply *string) er return nil } -func (r *RPCServer) ListUserProjects(args *data.RPCUserProjectsArgs, reply *[]data.Project) error { +func (r *RPCServer) ListUserProjects(args *data.RPCUserProjectsArgs, reply *[]data.UserProject) error { log.Printf("Listing projects for user: %s", args.GithubLogin) projects, err := r.models.GetProjectsForUser(args.GithubLogin) if err != nil { diff --git a/logwolf-server/toolbox/data/project.go b/logwolf-server/toolbox/data/project.go index 889474f..6980def 100644 --- a/logwolf-server/toolbox/data/project.go +++ b/logwolf-server/toolbox/data/project.go @@ -38,6 +38,14 @@ type ProjectMember struct { CreatedAt time.Time `bson:"created_at" json:"created_at"` } +// UserProject is a project as seen by one user: the project itself plus the +// role that user holds in it. Callers listing "my projects" need both, and the +// role is never a property of the project on its own. +type UserProject struct { + Project + Role string `json:"role"` +} + // RPC argument types for project and member operations. // RPCCreateProjectArgs is the RPC argument for CreateProject. @@ -299,7 +307,9 @@ func (m *Models) GetAllProjects(ctx context.Context) ([]Project, error) { return projects, nil } -func (m *Models) GetProjectsForUser(githubLogin string) ([]Project, error) { +// GetProjectsForUser returns every project the user is a member of, each paired +// with the role they hold in it. +func (m *Models) GetProjectsForUser(githubLogin string) ([]UserProject, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -315,12 +325,14 @@ func (m *Models) GetProjectsForUser(githubLogin string) ([]Project, error) { } if len(members) == 0 { - return []Project{}, nil + return []UserProject{}, nil } ids := make([]primitive.ObjectID, len(members)) + roles := make(map[primitive.ObjectID]string, len(members)) for i, mb := range members { ids[i] = mb.ProjectID + roles[mb.ProjectID] = mb.Role } projectCursor, err := m.client.Database("logs").Collection("projects").Find(ctx, bson.M{"_id": bson.M{"$in": ids}}) @@ -333,5 +345,12 @@ func (m *Models) GetProjectsForUser(githubLogin string) ([]Project, error) { if err := projectCursor.All(ctx, &projects); err != nil { return nil, fmt.Errorf("GetProjectsForUser projects decode: %w", err) } - return projects, nil + + // A membership row can outlive its project (the project was deleted while the + // row lingers); the projects query is what decides which entries survive. + result := make([]UserProject, 0, len(projects)) + for _, p := range projects { + result = append(result, UserProject{Project: p, Role: roles[p.ID]}) + } + return result, nil }