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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This app is intended to run alongside the Seamless Auth API as part of a self-ho
- filter and investigate authentication events
- review suspicious activity and anomaly signals
- edit system configuration
- configure allowed login methods and OAuth providers
- operate with runtime config injection in containerized environments

## Tech Stack
Expand Down Expand Up @@ -68,6 +69,55 @@ That runtime-config flow is intentional. The dashboard is designed to be reconfi
### System Configuration

- manage available roles and auth settings
- enable or disable login methods such as passkeys, magic links, OTP, and OAuth
- configure OAuth providers without entering provider client secrets in the browser

#### OAuth Provider Configuration

The dashboard edits the Seamless Auth API `oauth_providers` system config. OAuth is enabled by
turning on the `OAuth` login method and adding one or more provider records.

Each provider record includes:

- provider id, such as `google` or `github`
- display name
- client id
- `clientSecretEnv`, the name of the server environment variable holding the client secret
- authorization URL
- token URL
- userinfo URL
- requested scopes
- JSON paths used to read provider subject, email, and name from the userinfo response
- optional redirect URI
- signup policy

The dashboard intentionally does **not** collect provider client-secret values. Store those secrets
on the Seamless Auth API host and reference them by environment variable name, for example
`GOOGLE_CLIENT_SECRET`.

Example provider configuration:

```json
{
"id": "google",
"name": "Google",
"enabled": true,
"clientId": "google-oauth-client-id",
"clientSecretEnv": "GOOGLE_CLIENT_SECRET",
"authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth",
"tokenUrl": "https://oauth2.googleapis.com/token",
"userInfoUrl": "https://openidconnect.googleapis.com/v1/userinfo",
"scopes": ["openid", "email", "profile"],
"redirectUri": "https://app.example.com/oauth/callback",
"subjectJsonPath": "sub",
"emailJsonPath": "email",
"nameJsonPath": "name",
"allowSignup": true
}
```

After saving config, clients can discover enabled providers with `GET /oauth/providers` and start
login with `POST /oauth/:providerId/start`.

### Appearance

Expand Down
47 changes: 34 additions & 13 deletions src/components/PieChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { buildEventQuery } from "../lib/eventNavigation";

type PieChartDatum = {
type: string;
label?: string;
count: number;
};

Expand All @@ -29,11 +30,15 @@ function generateColor(index: number) {
"var(--primary)",
"var(--accent)",
"var(--highlight)",
"#8C6A5D",
"#5A7D7C",
"#D4A373",
"#7F5539",
"#6B9080",
"color-mix(in srgb, var(--primary) 70%, var(--accent))",
"color-mix(in srgb, var(--accent) 70%, var(--highlight))",
"color-mix(in srgb, var(--highlight) 70%, var(--primary))",
"color-mix(in srgb, var(--primary) 55%, var(--surface-alt))",
"color-mix(in srgb, var(--accent) 55%, var(--surface-alt))",
"color-mix(in srgb, var(--highlight) 55%, var(--surface-alt))",
"color-mix(in srgb, var(--primary) 65%, var(--text-muted))",
"color-mix(in srgb, var(--accent) 65%, var(--text-muted))",
"color-mix(in srgb, var(--highlight) 65%, var(--text-muted))",
];

return palette[index % palette.length];
Expand All @@ -43,15 +48,29 @@ function generateColor(index: number) {

export default function PieChart({ data }: { data: PieChartDatum[] }) {
const navigate = useNavigate();
const chartData = data
.filter((item) => item.count > 0)
.map((item) => ({
...item,
label: item.label ?? item.type,
}));

if (chartData.length === 0) {
return (
<div className="flex h-80 w-full items-center justify-center rounded-xl border border-dashed border-subtle bg-surface-alt text-sm text-muted">
No event data yet
</div>
);
}

return (
<div className="h-64 w-full">
<div className="h-80 w-full">
<ResponsiveContainer>
<RPieChart>
<Pie
data={data}
data={chartData}
dataKey="count"
nameKey="type"
nameKey="label"
outerRadius={90}
innerRadius={50}
paddingAngle={0}
Expand All @@ -62,13 +81,13 @@ export default function PieChart({ data }: { data: PieChartDatum[] }) {
const e = entry as unknown as PieChartDatum;

navigate(
buildEventQuery({
type: e.type,
}),
e.type === "other"
? "/events"
: buildEventQuery({ type: e.type }),
);
}}
>
{data.map((_, i) => (
{chartData.map((_, i) => (
<Cell
key={i}
fill={generateColor(i)}
Expand All @@ -87,8 +106,10 @@ export default function PieChart({ data }: { data: PieChartDatum[] }) {
/>

<Legend
iconSize={8}
wrapperStyle={{
fontSize: "12px",
fontSize: "11px",
lineHeight: "16px",
color: "var(--text-muted)",
}}
/>
Expand Down
22 changes: 20 additions & 2 deletions src/hooks/useGroupedEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,34 @@

import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
import { categorizeEventSummary } from "../lib/eventCategories";

export interface groupedEvents {
type EventSummaryResponse = {
summary: {
type: string;
count: number;
}[];
};

export interface GroupedEvents {
summary: {
type: string;
label: string;
count: number;
}[];
}

export function useGroupedEvents() {
return useQuery({
queryKey: ["grouped-events"],
queryFn: () => apiFetch<groupedEvents>("/internal/auth-events/grouped"),
queryFn: async (): Promise<GroupedEvents> => {
const data = await apiFetch<EventSummaryResponse>(
"/internal/auth-events/summary",
);

return {
summary: categorizeEventSummary(data.summary),
};
},
});
}
25 changes: 24 additions & 1 deletion src/hooks/useSystemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,29 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";

export type LoginMethod = "passkey" | "magic_link" | "email_otp" | "phone_otp";
export type LoginMethod =
| "passkey"
| "magic_link"
| "email_otp"
| "phone_otp"
| "oauth";

export type OAuthProviderConfig = {
id: string;
name: string;
enabled: boolean;
clientId: string;
clientSecretEnv: string;
authorizationUrl: string;
tokenUrl: string;
userInfoUrl: string;
scopes: string[];
redirectUri?: string;
subjectJsonPath: string;
emailJsonPath: string;
nameJsonPath?: string;
allowSignup: boolean;
};

export type SystemConfig = {
app_name: string;
Expand All @@ -20,6 +42,7 @@ export type SystemConfig = {
delay_after: number;
login_methods: LoginMethod[];
passkey_login_fallback_enabled: boolean;
oauth_providers: OAuthProviderConfig[];
rpid: string;
origins: string[];
};
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useUpdateSystemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
import type { LoginMethod } from "./useSystemConfig";
import type { LoginMethod, OAuthProviderConfig } from "./useSystemConfig";

export type SystemConfig = {
app_name: string;
Expand All @@ -18,6 +18,7 @@ export type SystemConfig = {
delay_after: number;
login_methods: LoginMethod[];
passkey_login_fallback_enabled: boolean;
oauth_providers: OAuthProviderConfig[];
rpid: string;
origins: string[];
};
Expand Down
39 changes: 39 additions & 0 deletions src/lib/eventCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { describe, expect, it } from "vitest";
import { categorizeEventSummary, getEventCategory } from "./eventCategories";

describe("eventCategories", () => {
it("classifies concrete API event types into operator categories", () => {
expect(getEventCategory("oauth_login_success").value).toBe("oauth");
expect(getEventCategory("webauthn_login_success").value).toBe("webauthn");
expect(getEventCategory("refresh_token_failed").value).toBe("token");
expect(getEventCategory("service_token_success").value).toBe(
"serviceToken",
);
expect(getEventCategory("step_up_challenge").value).toBe("stepUp");
expect(getEventCategory("login_suspicious").value).toBe("security");
});

it("groups raw event summaries and only keeps populated categories", () => {
expect(
categorizeEventSummary([
{ type: "login_success", count: 8 },
{ type: "refresh_token_failed", count: 5 },
{ type: "service_token_success", count: 3 },
{ type: "oauth_login_failed", count: 4 },
{ type: "unknown_backend_event", count: 2 },
]),
).toEqual([
{ type: "login", label: "Login", count: 8 },
{ type: "token", label: "Session Tokens", count: 5 },
{ type: "oauth", label: "OAuth", count: 4 },
{ type: "serviceToken", label: "Service Tokens", count: 3 },
{ type: "other", label: "Other", count: 2 },
]);
});
});
Loading
Loading