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
}