Files
silo-server/web/src/pages/ActivateDevice.tsx
T
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00

219 lines
7.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Link, useSearchParams } from "react-router";
import { api } from "@/api/client";
import type { DeviceLoginLookupResponse } from "@/api/types";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useServerBranding } from "@/hooks/useServerBranding";
import { AuthBackground } from "@/components/auth/AuthBackground";
import { toast } from "sonner";
function normalizeCode(value: string) {
const clean = value
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 8);
if (clean.length <= 4) {
return clean;
}
return `${clean.slice(0, 4)}-${clean.slice(4)}`;
}
export default function ActivateDevice() {
const { user, loading, setupLoading } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [codeInput, setCodeInput] = useState(searchParams.get("code") ?? "");
const [details, setDetails] = useState<DeviceLoginLookupResponse | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const [acting, setActing] = useState(false);
const { serverName } = useServerBranding();
useDocumentTitle("Approve Device");
const token = searchParams.get("token") ?? "";
const code = searchParams.get("code") ?? "";
const redirectTarget = useMemo(() => {
const query = searchParams.toString();
return query ? `/activate?${query}` : "/activate";
}, [searchParams]);
const loadDetails = useCallback(async () => {
if (!token && !code) {
setDetails(null);
return;
}
setLoadingDetails(true);
try {
const params = new URLSearchParams();
if (token) {
params.set("token", token);
} else {
params.set("code", code);
}
const result = await api<DeviceLoginLookupResponse>(`/auth/device?${params.toString()}`);
setDetails(result);
} catch (error) {
setDetails(null);
toast.error(error instanceof Error ? error.message : "Device request not found");
} finally {
setLoadingDetails(false);
}
}, [code, token]);
useEffect(() => {
void loadDetails();
}, [loadDetails]);
async function handleDecision(action: "approve" | "deny") {
setActing(true);
try {
await api(`/auth/device/${action}`, {
method: "POST",
body: JSON.stringify({ token, code }),
});
await loadDetails();
} catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to ${action} request`);
} finally {
setActing(false);
}
}
function handleCodeSubmit(e: FormEvent) {
e.preventDefault();
const normalized = normalizeCode(codeInput);
if (!normalized) {
return;
}
setSearchParams({ code: normalized });
}
const loginHref = `/login?redirect=${encodeURIComponent(redirectTarget)}`;
return (
<div className="auth-shell">
<AuthBackground />
<Card className="auth-card glass panel-border w-full max-w-md border-0">
<CardHeader>
<CardTitle className="text-3xl font-extrabold tracking-[-0.04em]">{serverName}</CardTitle>
<CardDescription className="mt-2 text-sm leading-6">
Approve sign-in for the device you&apos;re trying to use.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!token && !code ? (
<form onSubmit={handleCodeSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="device-code">Enter the code from your screen</Label>
<Input
id="device-code"
value={codeInput}
onChange={(e) => setCodeInput(normalizeCode(e.target.value))}
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
placeholder="ABCD-EFGH"
/>
</div>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
) : loadingDetails || loading || setupLoading ? (
<div className="text-muted-foreground text-sm">Loading device request...</div>
) : !details ? (
<div className="space-y-4">
<p className="text-sm">That sign-in request could not be found.</p>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setSearchParams({})}
>
Enter another code
</Button>
</div>
) : (
<div className="space-y-4">
<div className="border-border/60 bg-background/50 rounded-md border p-4">
<div className="space-y-1">
<div className="text-lg font-semibold">
{details.device_name || "This device"}
</div>
{details.device_platform ? (
<div className="text-muted-foreground text-sm">{details.device_platform}</div>
) : null}
{details.ip_address_hint ? (
<div className="text-muted-foreground text-sm">{details.ip_address_hint}</div>
) : null}
</div>
{details.match_code ? (
<div className="mt-4">
<div className="text-muted-foreground text-xs tracking-[0.12em] uppercase">
Match code
</div>
<div className="text-lg font-semibold">{details.match_code}</div>
</div>
) : null}
</div>
{!user ? (
<Button asChild className="w-full">
<Link to={loginHref}>Sign in to approve</Link>
</Button>
) : details.status === "pending" ? (
<div className="space-y-3">
<p className="text-sm">
Signed in as <span className="font-medium">{user.username}</span>.
</p>
<Button
className="w-full"
disabled={acting}
onClick={() => void handleDecision("approve")}
>
{acting ? "Approving..." : "Approve sign-in"}
</Button>
<Button
variant="outline"
className="w-full"
disabled={acting}
onClick={() => void handleDecision("deny")}
>
Deny
</Button>
</div>
) : details.status === "approved" ? (
<p className="text-sm">Approved. Finish sign-in on the device.</p>
) : details.status === "consumed" ? (
<p className="text-sm">This device is already signed in.</p>
) : details.status === "denied" ? (
<p className="text-sm">This sign-in request was denied.</p>
) : (
<p className="text-sm">This sign-in request has expired.</p>
)}
{!token ? (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setSearchParams({})}
>
Enter another code
</Button>
) : null}
</div>
)}
</CardContent>
</Card>
</div>
);
}