Skip to content

Commit eaad50b

Browse files
committed
feat(dashboard/contract): AuthGate prefers extension-contributed /login route, polish built-in form
Two follow-ons to slice (l): 1. The auth extension now owns the login UI by default. AuthGate fetches useContractGraph(loginContributor, "/login") before falling back to the built-in LoginScreen. authsome (or any extension) registers a single `/login` graph route under its contributor (default "auth") to take over — no React code, just YAML + a command intent. - extension.go: bootstrap config gains `loginContributor: "auth"`. - runtime/config.ts: surfaces `loginContributor` (default "auth"). - auth/AuthGate.tsx: when authRequired, fetches the contract /login graph; renders it via GraphRenderer wrapped in ContributorProvider/RouteParamsProvider when the extension owns it. 404 / no graph → built-in LoginScreen. 2. Built-in LoginScreen polished to match the latest shadcn login-03 layout that ships with the Sidebar block from ui.shadcn.com: - Centered card on a subtle bg-muted/40 background. - Brand lockup above the card (LayoutDashboard icon + "Forge Dashboard" by default; override via prop). - "Welcome back" heading + concise description copy. - Email/password inputs with placeholder, "Forgot password?" link beside the password label, full-width primary submit button. - Inline error block with AlertCircle icon for failed auth. - Footer caption beneath the card. Tests: AuthGate test split into three cases — pass-through, contract-owned /login override, fallback to built-in form on 404. 33 React tests green.
1 parent 1cb135d commit eaad50b

5 files changed

Lines changed: 196 additions & 44 deletions

File tree

extensions/dashboard/contract/shell/src/auth/AuthGate.tsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,64 @@ import * as React from "react";
22
import { usePrincipalStore } from "./principal";
33
import { LoginScreen } from "./LoginScreen";
44
import { LoadingNode } from "../runtime/fallbacks";
5+
import { GraphRenderer } from "../runtime/renderer";
6+
import { ContributorProvider, RouteParamsProvider } from "../runtime/context";
7+
import { useContractGraph } from "../contract/hooks";
8+
import { ContractClientError } from "../contract/client";
9+
import { loginContributor } from "../runtime/config";
510

611
interface AuthGateProps {
712
children: React.ReactNode;
813
}
914

15+
const LOGIN_ROUTE = "/login";
16+
1017
/**
1118
* AuthGate sits between the router and the dashboard layout. While the
1219
* principal is loading the gate renders a spinner; once loaded it either
1320
* passes through (auth disabled or user authenticated) or replaces the tree
14-
* with the built-in LoginScreen (auth enabled but unauthenticated).
21+
* with a login UI.
1522
*
16-
* This is the slice (l) auth gate. Auth extensions that ship a contract
17-
* /login route bypass the LoginScreen by handling the login envelope on the
18-
* server side so the next /principal call succeeds; the gate is therefore
19-
* also the integration seam — no React-side hook required for the extension.
23+
* Slice (l) login UI sourcing — preferred path is the auth extension's
24+
* contract `/login` graph route under its contributor (default `auth`).
25+
* The gate fetches it; if the contributor or route is missing the gate
26+
* falls back to the built-in `LoginScreen` so the shell still works
27+
* out-of-the-box. This means authsome (or any auth extension) owns the
28+
* login UI by registering one graph node, no React code required.
2029
*/
2130
export function AuthGate({ children }: AuthGateProps) {
2231
const loaded = usePrincipalStore((s) => s.loaded);
2332
const authRequired = usePrincipalStore((s) => s.authRequired);
2433

2534
if (!loaded) return <LoadingNode />;
26-
if (authRequired) return <LoginScreen />;
35+
if (authRequired) return <LoginGate />;
2736
return <>{children}</>;
2837
}
38+
39+
function LoginGate() {
40+
const { data, error, isLoading } = useContractGraph(loginContributor, LOGIN_ROUTE);
41+
42+
if (isLoading) return <LoadingNode />;
43+
44+
// 404 (no contract /login route registered) → fall back to the built-in
45+
// form. Any other error also falls through; the LoginScreen submission
46+
// surfaces command-level errors of its own.
47+
if (error || !data) {
48+
return <LoginScreen />;
49+
}
50+
51+
// The auth extension registered a /login route — render its graph as the
52+
// login surface. Wrap with the contributor + route-params context the
53+
// GraphRenderer expects so leaf intents (form.edit submitting auth.login)
54+
// resolve correctly.
55+
return (
56+
<ContributorProvider value={loginContributor}>
57+
<RouteParamsProvider value={data.routeParams}>
58+
<GraphRenderer node={data.node} />
59+
</RouteParamsProvider>
60+
</ContributorProvider>
61+
);
62+
}
63+
64+
// Re-export for tests + external callers that need to inspect the error type.
65+
export { ContractClientError };
Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import * as React from "react";
2-
import { LogIn } from "lucide-react";
2+
import { AlertCircle, LayoutDashboard } from "lucide-react";
33
import { Button } from "@/components/ui/button";
4-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
54
import { Input } from "@/components/ui/input";
65
import { Label } from "@/components/ui/label";
76
import { ContractClient, ContractClientError } from "../contract/client";
87
import { usePrincipalStore } from "./principal";
98
import { loginOp } from "../runtime/config";
9+
import { cn } from "@/lib/utils";
1010

1111
const DEFAULT_AUTH_CONTRIBUTOR = "auth";
1212

@@ -15,19 +15,26 @@ interface LoginScreenProps {
1515
contributor?: string;
1616
/** Override the command op. Defaults to runtime config's loginOp ("auth.login"). */
1717
op?: string;
18+
/** Override the brand label shown above the form. */
19+
brand?: string;
20+
/** Optional secondary description rendered below the title. */
21+
description?: string;
1822
}
1923

2024
/**
21-
* LoginScreen is the built-in fallback rendered by AuthGate when the
22-
* /principal endpoint returns 401. It issues a `kind: command` envelope to
23-
* the configured `loginOp` (default "auth.login") and on success reloads the
24-
* principal so the gate releases. Auth extensions that prefer a richer flow
25-
* register a contract /login graph route — AuthGate prefers that path when
26-
* available.
25+
* LoginScreen is the built-in fallback rendered by AuthGate when no contract
26+
* /login route is registered. Visual style follows the latest shadcn
27+
* "login-03" layout — centered card on a subtle muted background, branded
28+
* lockup above, polished form controls. Authsome-style auth extensions
29+
* normally replace this entirely by publishing their own /login graph
30+
* route; this component exists so the shell ships with a working sign-in
31+
* UX out of the box.
2732
*/
2833
export function LoginScreen({
2934
contributor = DEFAULT_AUTH_CONTRIBUTOR,
3035
op,
36+
brand = "Forge Dashboard",
37+
description = "Sign in to continue.",
3138
}: LoginScreenProps) {
3239
const reloadPrincipal = usePrincipalStore((s) => s.load);
3340
const [email, setEmail] = React.useState("");
@@ -55,32 +62,38 @@ export function LoginScreen({
5562
};
5663

5764
return (
58-
<div className="flex min-h-svh items-center justify-center bg-background p-6">
59-
<Card className="w-full max-w-md">
60-
<CardHeader className="space-y-1 text-center">
61-
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
62-
<LogIn className="h-5 w-5" />
63-
</div>
64-
<CardTitle>Sign in</CardTitle>
65-
<CardDescription>
66-
Use your account credentials to continue to the dashboard.
67-
</CardDescription>
68-
</CardHeader>
69-
<CardContent>
70-
<form onSubmit={submit} className="space-y-4">
71-
<div className="space-y-2">
65+
<div className="flex min-h-svh w-full items-center justify-center bg-muted/40 p-6 md:p-10">
66+
<div className="flex w-full max-w-sm flex-col gap-6">
67+
<BrandLockup brand={brand} />
68+
<div className="flex flex-col gap-6 rounded-xl border border-border/60 bg-background p-6 shadow-sm sm:p-8">
69+
<header className="flex flex-col gap-1.5">
70+
<h1 className="text-xl font-semibold tracking-tight">Welcome back</h1>
71+
<p className="text-sm text-muted-foreground">{description}</p>
72+
</header>
73+
<form onSubmit={submit} className="flex flex-col gap-5">
74+
<div className="flex flex-col gap-2">
7275
<Label htmlFor="login-email">Email</Label>
7376
<Input
7477
id="login-email"
7578
type="email"
7679
autoComplete="email"
80+
placeholder="you@example.com"
7781
required
7882
value={email}
7983
onChange={(e) => setEmail(e.target.value)}
8084
/>
8185
</div>
82-
<div className="space-y-2">
83-
<Label htmlFor="login-password">Password</Label>
86+
<div className="flex flex-col gap-2">
87+
<div className="flex items-center justify-between">
88+
<Label htmlFor="login-password">Password</Label>
89+
<a
90+
href="#"
91+
className="text-xs font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
92+
tabIndex={-1}
93+
>
94+
Forgot password?
95+
</a>
96+
</div>
8497
<Input
8598
id="login-password"
8699
type="password"
@@ -91,16 +104,36 @@ export function LoginScreen({
91104
/>
92105
</div>
93106
{errorMsg ? (
94-
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
95-
{errorMsg}
107+
<div
108+
role="alert"
109+
className={cn(
110+
"flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive",
111+
)}
112+
>
113+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
114+
<span className="leading-tight">{errorMsg}</span>
96115
</div>
97116
) : null}
98117
<Button type="submit" className="w-full" disabled={submitting}>
99118
{submitting ? "Signing in…" : "Sign in"}
100119
</Button>
101120
</form>
102-
</CardContent>
103-
</Card>
121+
</div>
122+
<p className="text-center text-xs text-muted-foreground">
123+
Protected by your organization&apos;s sign-in policy.
124+
</p>
125+
</div>
126+
</div>
127+
);
128+
}
129+
130+
function BrandLockup({ brand }: { brand: string }) {
131+
return (
132+
<div className="flex flex-col items-center gap-2 text-center">
133+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-foreground text-background">
134+
<LayoutDashboard className="h-5 w-5" aria-hidden />
135+
</div>
136+
<span className="text-sm font-medium tracking-tight text-foreground">{brand}</span>
104137
</div>
105138
);
106139
}

extensions/dashboard/contract/shell/src/runtime/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ interface InjectedConfig {
2121
authEnabled?: boolean;
2222
loginPath?: string;
2323
loginOp?: string;
24+
// Slice (l): the contributor that owns the contract /login graph route.
25+
// AuthGate fetches `useContractGraph(loginContributor, "/login")` first;
26+
// if it 404s, the built-in LoginScreen renders. An auth extension drops
27+
// a /login route into its manifest under this contributor name to
28+
// override the form completely.
29+
loginContributor?: string;
2430
}
2531

2632
declare global {
@@ -44,3 +50,4 @@ export const shellBase: string = injected.shellBase ?? `${FALLBACK_BASE}/contrac
4450
export const authEnabled: boolean = injected.authEnabled ?? false;
4551
export const loginPath: string = injected.loginPath ?? `${FALLBACK_BASE}/login`;
4652
export const loginOp: string = injected.loginOp ?? "auth.login";
53+
export const loginContributor: string = injected.loginContributor ?? "auth";

extensions/dashboard/contract/shell/test/auth.test.tsx

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,24 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
22
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { http, HttpResponse } from "msw";
44
import { setupServer } from "msw/node";
5+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
56
import { AuthGate } from "../src/auth/AuthGate";
67
import { LoginScreen } from "../src/auth/LoginScreen";
78
import { usePrincipalStore } from "../src/auth/principal";
9+
import { IntentRegistryProvider } from "../src/runtime/context";
10+
import { buildIntentRegistry } from "../src/intents/register";
811

912
const server = setupServer();
13+
const intentRegistry = buildIntentRegistry();
14+
15+
function withProviders(ui: React.ReactElement) {
16+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
17+
return (
18+
<QueryClientProvider client={qc}>
19+
<IntentRegistryProvider value={intentRegistry}>{ui}</IntentRegistryProvider>
20+
</QueryClientProvider>
21+
);
22+
}
1023

1124
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
1225
afterEach(() => {
@@ -30,27 +43,83 @@ describe("AuthGate", () => {
3043
principal: { subject: "x", displayName: "X", roles: [], scopes: [] },
3144
});
3245
render(
33-
<AuthGate>
34-
<div>protected-content</div>
35-
</AuthGate>,
46+
withProviders(
47+
<AuthGate>
48+
<div>protected-content</div>
49+
</AuthGate>,
50+
),
3651
);
3752
expect(screen.getByText("protected-content")).toBeInTheDocument();
3853
});
3954

40-
it("renders LoginScreen when principal is loaded and auth required", () => {
55+
it("falls back to built-in LoginScreen when no contract /login route is registered", async () => {
56+
server.use(
57+
// No /login graph route → 404 envelope. AuthGate should fall through.
58+
http.post("/api/dashboard/v1", () =>
59+
HttpResponse.json(
60+
{ ok: false, envelope: "v1", error: { code: "NOT_FOUND", message: "no /login" } },
61+
{ status: 404 },
62+
),
63+
),
64+
);
4165
usePrincipalStore.setState({
4266
loaded: true,
4367
authRequired: true,
4468
principal: null,
4569
});
4670
render(
47-
<AuthGate>
48-
<div>protected-content</div>
49-
</AuthGate>,
71+
withProviders(
72+
<AuthGate>
73+
<div>protected-content</div>
74+
</AuthGate>,
75+
),
5076
);
5177
expect(screen.queryByText("protected-content")).not.toBeInTheDocument();
52-
// The login form has an email field — distinct enough to confirm it rendered.
53-
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
78+
await waitFor(() => {
79+
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
80+
});
81+
});
82+
83+
it("renders the auth extension's contract /login graph when registered", async () => {
84+
server.use(
85+
http.post("/api/dashboard/v1", () =>
86+
HttpResponse.json({
87+
ok: true,
88+
envelope: "v1",
89+
kind: "graph",
90+
data: {
91+
intent: "page.shell",
92+
route: "/login",
93+
slots: {
94+
main: [
95+
{
96+
intent: "custom",
97+
component: "extension-login-marker",
98+
props: { label: "extension-login-here" },
99+
},
100+
],
101+
},
102+
},
103+
meta: {},
104+
}),
105+
),
106+
);
107+
usePrincipalStore.setState({
108+
loaded: true,
109+
authRequired: true,
110+
principal: null,
111+
});
112+
render(
113+
withProviders(
114+
<AuthGate>
115+
<div>protected-content</div>
116+
</AuthGate>,
117+
),
118+
);
119+
// Built-in form should not appear when the contract owns /login.
120+
await waitFor(() => {
121+
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
122+
});
54123
});
55124
});
56125

extensions/dashboard/extension.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1760,7 +1760,13 @@ func (e *Extension) makeShellSPAHandler(shellFS fs.FS) http.HandlerFunc {
17601760
"shellBase": e.config.BasePath + "/contract/app",
17611761
"authEnabled": e.config.EnableAuth,
17621762
"loginPath": e.config.BasePath + e.config.LoginPath,
1763-
"loginOp": "auth.login",
1763+
// Slice (l): the contributor that owns the contract /login graph
1764+
// route. Auth extensions like authsome register a `/login` route
1765+
// under their own contributor and the shell renders that page
1766+
// instead of the built-in LoginScreen. Default "auth" for
1767+
// authsome; deployments can override via config later.
1768+
"loginContributor": "auth",
1769+
"loginOp": "auth.login",
17641770
}
17651771
cfgJSON, _ := json.Marshal(cfg)
17661772
bootstrap := []byte("<script>window.__FORGE_DASHBOARD__=" + string(cfgJSON) + ";</script>")

0 commit comments

Comments
 (0)