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,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.

Expand Down
11 changes: 9 additions & 2 deletions logwolf-server/broker/cmd/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 19 additions & 8 deletions logwolf-server/broker/docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -82,6 +82,13 @@ export function ProjectSwitcher({ projects, currentProject, csrfToken }: Props)

<DropdownMenuSeparator />

<DropdownMenuItem asChild>
<Link to='/projects'>
<LayoutList />
<span>All projects</span>
</Link>
</DropdownMenuItem>

<DropdownMenuItem asChild>
<Link to='/projects/new'>
<Plus />
Expand Down
17 changes: 17 additions & 0 deletions logwolf-server/frontend/app/hooks/use-projects.ts
Original file line number Diff line number Diff line change
@@ -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<typeof layoutLoader>('pages/layout');

return {
projects: layoutData?.projects ?? [],
currentProject: layoutData?.currentProject,
};
}
24 changes: 21 additions & 3 deletions logwolf-server/frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,7 +34,8 @@ export type Metrics = {
};

export interface IApi {
getProjects(): Promise<Project[]>;
getProjects(): Promise<UserProject[]>;
createProject(name: string, slug: string): Promise<Project>;
getKeys(projectId: string): Promise<ApiKey[]>;
createKey(projectId: string): Promise<{ key: string; prefix: string; id: string }>;
deleteKey(id: string): Promise<void>;
Expand All @@ -53,12 +59,24 @@ export class Api implements IApi {
};
}

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

return json.data;
}

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

return json.data;
Expand Down
20 changes: 20 additions & 0 deletions logwolf-server/frontend/app/lib/slug.ts
Original file line number Diff line number Diff line change
@@ -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, '')
);
}
9 changes: 8 additions & 1 deletion logwolf-server/frontend/app/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions logwolf-server/frontend/app/pages/projects/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Page title='Projects'>
<Section
title='Projects'
addon={
<Button asChild>
<Link to='/projects/new'>
<Plus />
New project
</Link>
</Button>
}
>
<div className='flex flex-col gap-2'>
{projects.map((project) => (
<Card key={project.id} className='shadow-none py-0'>
<CardContent className='px-0'>
<button
type='button'
onClick={() => open(project.id)}
className='flex w-full flex-row items-center justify-between gap-4 rounded-xl px-6 py-3 text-left hover:bg-accent'
>
<div className='flex flex-row items-center gap-3 min-w-0'>
<span className='truncate font-medium'>{project.name}</span>
<code className='truncate text-sm text-muted-foreground'>{project.slug}</code>

<Badge variant={project.role === 'owner' ? 'default' : 'secondary'}>{project.role}</Badge>

{project.id === currentProject?.id && (
<Badge variant='outline'>
<Check />
Current
</Badge>
)}
</div>

<span className='text-xs text-muted-foreground whitespace-nowrap'>
Created {new Date(project.created_at).toLocaleDateString()}
</span>
</button>
</CardContent>
</Card>
))}
</div>
</Section>
</Page>
);
}
Loading
Loading