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
26 changes: 26 additions & 0 deletions dashboard/src/features/auth/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { oauthStartUrl } from "./api";

describe("oauthStartUrl", () => {
it("returns the slot-rooted path when no next is supplied", () => {
expect(oauthStartUrl("google")).toBe("/api/auth/oauth/start/google");
});

it("URL-encodes the next path so it survives querystring parsing", () => {
expect(oauthStartUrl("google", "/jobs?status=failed")).toBe(
"/api/auth/oauth/start/google?next=%2Fjobs%3Fstatus%3Dfailed",
);
});

it("URL-encodes provider slots that contain reserved characters", () => {
// OIDC slot names must match ^[a-z][a-z0-9_-]{0,31}$ at the server,
// so this is defence-in-depth — but the encoding must not break the
// slot regex on the way out.
expect(oauthStartUrl("acme-okta", "/")).toBe("/api/auth/oauth/start/acme-okta?next=%2F");
});

it("ignores empty / undefined next gracefully", () => {
expect(oauthStartUrl("github", undefined)).toBe("/api/auth/oauth/start/github");
expect(oauthStartUrl("github", "")).toBe("/api/auth/oauth/start/github");
});
});
24 changes: 23 additions & 1 deletion dashboard/src/features/auth/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { api } from "@/lib/api-client";
import type { AuthStatus, LoginResponse, SetupResponse, WhoamiResponse } from "./types";
import type {
AuthStatus,
LoginResponse,
ProvidersResponse,
SetupResponse,
WhoamiResponse,
} from "./types";

export function fetchAuthStatus(signal?: AbortSignal): Promise<AuthStatus> {
return api.get<AuthStatus>("/api/auth/status", { signal });
}

export function fetchProviders(signal?: AbortSignal): Promise<ProvidersResponse> {
return api.get<ProvidersResponse>("/api/auth/providers", { signal });
}

/** Browser URL the user is sent to when they click an OAuth provider button.
*
* The server's ``/api/auth/oauth/start/{slot}`` endpoint will mint state and
* 302 to the provider. We append ``next`` so the post-login callback can
* land the user back where they were trying to go.
*/
export function oauthStartUrl(slot: string, next?: string): string {
const base = `/api/auth/oauth/start/${encodeURIComponent(slot)}`;
if (!next) return base;
return `${base}?next=${encodeURIComponent(next)}`;
}

export function fetchWhoami(signal?: AbortSignal): Promise<WhoamiResponse> {
return api.get<WhoamiResponse>("/api/auth/whoami", { signal });
}
Expand Down
121 changes: 83 additions & 38 deletions dashboard/src/features/auth/components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { AlertCircle, LogIn } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui";
import { Input } from "@/components/ui/input";
import { ApiError } from "@/lib/api-client";
import { useLogin } from "../hooks";
import { useAuthProviders, useLogin } from "../hooks";
import { OAuthButton } from "./oauth-button";

const ERROR_MESSAGES: Record<string, string> = {
invalid_credentials: "Invalid username or password.",
Expand All @@ -13,17 +14,27 @@ const ERROR_MESSAGES: Record<string, string> = {

export function LoginForm() {
const navigate = useNavigate();
const search = useSearch({ strict: false }) as { next?: string } | undefined;
const nextPath = typeof search?.next === "string" ? search.next : undefined;

const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const login = useLogin();
const providers = useAuthProviders();

// Default to password-on while the providers query is in flight so the
// form doesn't flash empty on the first render.
const passwordEnabled = providers.data?.password_enabled ?? true;
const oauthProviders = providers.data?.providers ?? [];
const hasOAuth = oauthProviders.length > 0;

function onSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
login.mutate(
{ username, password },
{
onSuccess: () => {
void navigate({ to: "/" });
void navigate({ to: nextPath ?? "/" });
},
},
);
Expand All @@ -33,52 +44,86 @@ export function LoginForm() {
const disabled = login.isPending || !username || !password;

return (
<form
onSubmit={onSubmit}
className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6 shadow-sm"
>
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--surface-1)] p-6 shadow-sm">
<div>
<h1 className="text-lg font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-[var(--fg-muted)]">
Enter your dashboard credentials to continue.
{passwordEnabled
? "Enter your dashboard credentials to continue."
: "Choose a provider to continue."}
</p>
</div>
<label htmlFor="login-username" className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Username</span>
<Input
id="login-username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</label>
<label htmlFor="login-password" className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Password</span>
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? (

{hasOAuth ? (
<div className="flex flex-col gap-2">
{oauthProviders.map((provider) => (
<OAuthButton key={provider.slot} provider={provider} next={nextPath} />
))}
</div>
) : null}

{hasOAuth && passwordEnabled ? (
<div className="flex items-center gap-3 text-xs text-[var(--fg-subtle)]">
<div className="h-px flex-1 bg-[var(--border)]" />
<span>or sign in with password</span>
<div className="h-px flex-1 bg-[var(--border)]" />
</div>
) : null}

{passwordEnabled ? (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<label htmlFor="login-username" className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Username</span>
<Input
id="login-username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus={!hasOAuth}
/>
</label>
<label htmlFor="login-password" className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Password</span>
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? (
<div
role="alert"
className="flex items-start gap-2 rounded-md bg-danger-dim px-3 py-2 text-sm text-danger"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{error}</span>
</div>
) : null}
<Button type="submit" disabled={disabled}>
<LogIn aria-hidden /> {login.isPending ? "Signing in…" : "Sign in"}
</Button>
</form>
) : null}

{!passwordEnabled && !hasOAuth ? (
<div
role="alert"
className="flex items-start gap-2 rounded-md bg-danger-dim px-3 py-2 text-sm text-danger"
className="flex items-start gap-2 rounded-md bg-warning-dim px-3 py-2 text-sm text-warning"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{error}</span>
<span>
No login methods are configured. Set{" "}
<code>TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=true</code> or configure an OAuth
provider.
</span>
</div>
) : null}
<Button type="submit" disabled={disabled}>
<LogIn aria-hidden /> {login.isPending ? "Signing in…" : "Sign in"}
</Button>
</form>
</div>
);
}

Expand Down
85 changes: 85 additions & 0 deletions dashboard/src/features/auth/components/oauth-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { KeyRound } from "lucide-react";
import { oauthStartUrl } from "../api";
import type { AuthProvider } from "../types";

interface OAuthButtonProps {
provider: AuthProvider;
/** Path to send the user to after a successful login. Validated server-side. */
next?: string;
}

/** "Sign in with X" button — renders as a plain anchor so the browser
* follows the 302 from ``/api/auth/oauth/start/{slot}`` natively.
*
* Styling matches the dashboard's design system without depending on the
* primary :class:`Button` component (we need anchor semantics, not button).
*/
export function OAuthButton({ provider, next }: OAuthButtonProps) {
return (
<a
href={oauthStartUrl(provider.slot, next)}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-[var(--fg)] transition-colors hover:bg-[var(--surface-3)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-1)]"
data-slot={provider.slot}
data-type={provider.type}
>
<ProviderIcon type={provider.type} />
<span>Continue with {provider.label}</span>
</a>
);
}

function ProviderIcon({ type }: { type: AuthProvider["type"] }) {
if (type === "google") {
return <GoogleGlyph />;
}
if (type === "github") {
return <GitHubGlyph />;
}
// Generic OIDC — operator-configured SSO.
return <KeyRound className="size-4" aria-hidden />;
}

/** Official Google "G" mark — inlined SVG so we don't pull in a brand-asset
* dependency. Matches Google's brand guidelines for sign-in buttons.
*/
function GoogleGlyph() {
return (
<svg viewBox="0 0 24 24" className="size-4" role="img" aria-label="Google" focusable="false">
<title>Google</title>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.25 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.2 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z"
/>
</svg>
);
}

/** GitHub Octocat (Mark) — inlined so we don't depend on a brand-icon set
* that might drop it (lucide-react 1.x removed brand icons).
*/
function GitHubGlyph() {
return (
<svg
viewBox="0 0 24 24"
className="size-4 fill-current"
role="img"
aria-label="GitHub"
focusable="false"
>
<title>GitHub</title>
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.21c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.69 1.25 3.35.96.1-.74.4-1.25.73-1.54-2.55-.29-5.23-1.27-5.23-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.02 0 0 .96-.31 3.15 1.17a10.9 10.9 0 0 1 5.74 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.57.23 2.73.11 3.02.73.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.36.78 1.05.78 2.12v3.14c0 .31.21.67.79.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
);
}
24 changes: 23 additions & 1 deletion dashboard/src/features/auth/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/api-client";
import { changePassword, fetchAuthStatus, fetchWhoami, login, logout, setup } from "./api";
import {
changePassword,
fetchAuthStatus,
fetchProviders,
fetchWhoami,
login,
logout,
setup,
} from "./api";
import type { WhoamiResponse } from "./types";

export const AUTH_STATUS_KEY = ["auth", "status"] as const;
export const WHOAMI_KEY = ["auth", "whoami"] as const;
export const PROVIDERS_KEY = ["auth", "providers"] as const;

export function authStatusQuery() {
return queryOptions({
Expand Down Expand Up @@ -42,6 +51,15 @@ export function whoamiQuery() {
});
}

/** List of OAuth providers exposed by the server. */
export function providersQuery() {
return queryOptions({
queryKey: PROVIDERS_KEY,
queryFn: ({ signal }) => fetchProviders(signal),
staleTime: 60_000,
});
}

export function useAuthStatus() {
return useQuery(authStatusQuery());
}
Expand All @@ -50,6 +68,10 @@ export function useWhoami() {
return useQuery(whoamiQuery());
}

export function useAuthProviders() {
return useQuery(providersQuery());
}

export function useLogin() {
const qc = useQueryClient();
return useMutation({
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export { AuthGate } from "./components/auth-gate";
export { LoginForm } from "./components/login-form";
export { OAuthButton } from "./components/oauth-button";
export { SetupForm } from "./components/setup-form";
export { UserMenu } from "./components/user-menu";
export {
authStatusQuery,
providersQuery,
useAuthProviders,
useAuthStatus,
useChangePassword,
useLogin,
Expand All @@ -13,10 +16,12 @@ export {
whoamiQuery,
} from "./hooks";
export type {
AuthProvider,
AuthSession,
AuthStatus,
AuthUser,
LoginResponse,
ProvidersResponse,
SetupResponse,
WhoamiResponse,
} from "./types";
Loading