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: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
cache-dependency-glob: pyproject.toml

- name: Install Python dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra oauth

- name: Lint Python with Ruff
run: uv run ruff check py_src/ tests/
Expand Down Expand Up @@ -262,7 +262,7 @@ jobs:
cache-dependency-glob: pyproject.toml

- name: Install Python dependencies
run: uv sync --extra dev
run: uv sync --extra dev --extra oauth

- name: Build native extension with maturin
uses: PyO3/maturin-action@v1
Expand Down
9 changes: 0 additions & 9 deletions Makefile

This file was deleted.

2 changes: 2 additions & 0 deletions dashboard/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Search } from "lucide-react";
import { Button, Kbd } from "@/components/ui";
import { UserMenu } from "@/features/auth";
import { useCommandPalette } from "@/providers";
import { LastRefreshed } from "./last-refreshed";
import { MobileMenu } from "./mobile-menu";
Expand Down Expand Up @@ -37,6 +38,7 @@ export function Header() {
<div className="ml-auto flex items-center gap-3">
<LastRefreshed className="hidden sm:inline-flex" />
<ThemeToggle />
<UserMenu />
</div>
</header>
);
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Server,
Settings2,
Skull,
Webhook as WebhookIcon,
} from "lucide-react";
import { useBranding, useExternalLinks } from "@/features/settings";
import { cn } from "@/lib/cn";
Expand Down Expand Up @@ -57,7 +58,11 @@ const NAV: NavGroup[] = [
},
{
title: "Configuration",
items: [{ to: "/settings", label: "Settings", icon: Cog }],
items: [
{ to: "/tasks", label: "Tasks", icon: ListTree },
{ to: "/webhooks", label: "Webhooks", icon: WebhookIcon },
{ to: "/settings", label: "Settings", icon: Cog },
],
},
];

Expand Down
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");
});
});
51 changes: 51 additions & 0 deletions dashboard/src/features/auth/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { api } from "@/lib/api-client";
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 });
}

export function login(username: string, password: string): Promise<LoginResponse> {
return api.post<LoginResponse>("/api/auth/login", { username, password });
}

export function logout(): Promise<{ ok: boolean }> {
return api.post<{ ok: boolean }>("/api/auth/logout");
}

export function setup(username: string, password: string): Promise<SetupResponse> {
return api.post<SetupResponse>("/api/auth/setup", { username, password });
}

export function changePassword(oldPassword: string, newPassword: string): Promise<{ ok: boolean }> {
return api.post<{ ok: boolean }>("/api/auth/change-password", {
old_password: oldPassword,
new_password: newPassword,
});
}
46 changes: 46 additions & 0 deletions dashboard/src/features/auth/components/auth-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useNavigate } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { Skeleton } from "@/components/ui";
import { useAuthStatus, useWhoami } from "../hooks";

/**
* Wraps the authenticated portion of the dashboard.
*
* - When setup is required, redirects to ``/login`` (which shows the setup
* form).
* - When the user isn't signed in, redirects to ``/login``.
* - While loading, renders a centered skeleton so the page never flashes
* raw content.
*
* Once a session resolves, children render normally.
*/
export function AuthGate({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const status = useAuthStatus();
const whoami = useWhoami();

const setupRequired = status.data?.setup_required === true;
const authenticated = !!whoami.data?.user;
const loading = status.isLoading || whoami.isLoading;

useEffect(() => {
if (loading) return;
if (setupRequired || !authenticated) {
void navigate({ to: "/login" });
}
}, [loading, setupRequired, authenticated, navigate]);

if (loading || setupRequired || !authenticated) {
return (
<div className="grid min-h-screen place-items-center">
<div className="flex flex-col items-center gap-3">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</div>
</div>
);
}

return <>{children}</>;
}
140 changes: 140 additions & 0 deletions dashboard/src/features/auth/components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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 { useAuthProviders, useLogin } from "../hooks";
import { OAuthButton } from "./oauth-button";

const ERROR_MESSAGES: Record<string, string> = {
invalid_credentials: "Invalid username or password.",
setup_required: "Dashboard setup is required before login.",
};

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: nextPath ?? "/" });
},
},
);
}

const error = errorMessage(login.error);
const disabled = login.isPending || !username || !password;

return (
<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)]">
{passwordEnabled
? "Enter your dashboard credentials to continue."
: "Choose a provider to continue."}
</p>
</div>

{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-warning-dim px-3 py-2 text-sm text-warning"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
No login methods are configured. Set{" "}
<code>TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=true</code> or configure an OAuth
provider.
</span>
</div>
) : null}
</div>
);
}

function errorMessage(error: unknown): string | null {
if (!error) return null;
if (error instanceof ApiError) {
const code =
typeof error.body === "object" && error.body && "error" in error.body
? String((error.body as { error: unknown }).error)
: "";
return ERROR_MESSAGES[code] ?? error.message ?? "Sign-in failed.";
}
return "Sign-in failed.";
}
Loading
Loading