fix(web): restore login page design and add runtime config injection (#435)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #435.
This commit is contained in:
2026-02-21 23:16:02 +00:00
committed by jason.woltje
parent 23d610ba5b
commit 1b66417be5
12 changed files with 416 additions and 150 deletions

View File

@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
describe("AuthDivider", (): void => {
it("should render with default text", (): void => {
render(<AuthDivider />);
expect(screen.getByText("or continue with email")).toBeInTheDocument();
expect(screen.getByText("or continue with")).toBeInTheDocument();
});
it("should render with custom text", (): void => {
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
expect(screen.getByText("or sign up")).toBeInTheDocument();
});
it("should render a horizontal divider line", (): void => {
it("should render horizontal divider lines", (): void => {
const { container } = render(<AuthDivider />);
const line = container.querySelector("span.border-t");
expect(line).toBeInTheDocument();
const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
expect(lines.length).toBe(2);
});
it("should apply uppercase styling to text", (): void => {

View File

@@ -1,18 +1,2 @@
interface AuthDividerProps {
text?: string;
}
export function AuthDivider({
text = "or continue with email",
}: AuthDividerProps): React.ReactElement {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500">{text}</span>
</div>
</div>
);
}
export { AuthDivider } from "@mosaic/ui";
export type { AuthDividerProps } from "@mosaic/ui";

View File

@@ -18,17 +18,10 @@ describe("AuthErrorBanner", (): void => {
expect(alert).toHaveAttribute("aria-live", "polite");
});
it("should render the info icon, not a warning icon", (): void => {
it("should render an icon", (): void => {
const { container } = render(<AuthErrorBanner message="Test message" />);
// Info icon from lucide-react renders as an SVG
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThanOrEqual(1);
// The container should use blue styling, not red/yellow
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("text-blue-700");
expect(alert.className).not.toContain("red");
expect(alert.className).not.toContain("yellow");
});
it("should render dismiss button when onDismiss is provided", (): void => {
@@ -54,14 +47,6 @@ describe("AuthErrorBanner", (): void => {
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("should use blue info styling, not red or alarming colors", (): void => {
render(<AuthErrorBanner message="Test" />);
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("border-blue-200");
expect(alert.className).toContain("text-blue-700");
});
it("should render all PDA-friendly error messages", (): void => {
const messages = [
"Authentication paused. Please try again when ready.",

View File

@@ -13,7 +13,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<div
role="alert"
aria-live="polite"
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
className="flex items-start gap-3 rounded-lg border border-[#f06a6f]/55 bg-[#fff1f2] p-4 text-[#9f1239] dark:border-[#e5484d]/55 dark:bg-[#3a111b]/70 dark:text-[#fecdd3]"
>
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="flex-1 text-sm">{message}</span>
@@ -21,7 +21,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<button
type="button"
onClick={onDismiss}
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
className="flex-shrink-0 text-[#be123c] transition-colors hover:text-[#881337] dark:text-[#fda4af] dark:hover:text-[#ffe4e6]"
aria-label="Dismiss"
>
<X className="h-4 w-4" aria-hidden="true" />

View File

@@ -9,12 +9,14 @@ export interface LoginFormProps {
onSubmit: (email: string, password: string) => void | Promise<void>;
isLoading?: boolean;
error?: string | null;
disabled?: boolean;
}
export function LoginForm({
onSubmit,
isLoading = false,
error = null,
disabled = false,
}: LoginFormProps): ReactElement {
const emailRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState("");
@@ -77,7 +79,10 @@ export function LoginForm({
)}
<div>
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="login-email"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Email
</label>
<input
@@ -91,13 +96,17 @@ export function LoginForm({
validateEmail(e.target.value);
}
}}
disabled={isLoading}
disabled={isLoading || disabled}
autoComplete="email"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
emailError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
emailError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
@@ -105,14 +114,21 @@ export function LoginForm({
aria-describedby={emailError ? "login-email-error" : undefined}
/>
{emailError && (
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
<p
id="login-email-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{emailError}
</p>
)}
</div>
<div>
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="login-password"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Password
</label>
<input
@@ -125,13 +141,17 @@ export function LoginForm({
validatePassword(e.target.value);
}
}}
disabled={isLoading}
disabled={isLoading || disabled}
autoComplete="current-password"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
passwordError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
passwordError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
@@ -139,7 +159,11 @@ export function LoginForm({
aria-describedby={passwordError ? "login-password-error" : undefined}
/>
{passwordError && (
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
<p
id="login-password-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{passwordError}
</p>
)}
@@ -147,13 +171,13 @@ export function LoginForm({
<button
type="submit"
disabled={isLoading}
disabled={isLoading || disabled}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
isLoading ? "opacity-50 pointer-events-none" : "",
"w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
"bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
isLoading || disabled ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
.join(" ")}

View File

@@ -13,10 +13,12 @@ export interface OAuthButtonProps {
export function OAuthButton({
providerName,
providerId,
onClick,
isLoading = false,
disabled = false,
}: OAuthButtonProps): ReactElement {
const accentColor = resolveProviderAccent(providerId);
const isDisabled = disabled || isLoading;
return (
@@ -27,10 +29,12 @@ export function OAuthButton({
disabled={isDisabled}
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
"w-full inline-flex items-center justify-center gap-2 rounded-lg",
"border border-[#b8c4de] bg-[#f8faff]/90 px-4 py-3 text-sm font-semibold text-[#2f3b52]",
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"hover:border-[#2f80ff] hover:bg-[#dde4f2] hover:text-[#0f141d]",
"dark:border-[#2f3b52] dark:bg-[#0f141d]/75 dark:text-[#c5d0e6]",
"dark:hover:border-[#2f80ff] dark:hover:bg-[#232d3f] dark:hover:text-[#eef3ff]",
isDisabled ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
@@ -42,8 +46,33 @@ export function OAuthButton({
<span>Connecting...</span>
</>
) : (
<span>Continue with {providerName}</span>
<>
<span
aria-hidden="true"
className="h-2 w-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span>Continue with {providerName}</span>
</>
)}
</button>
);
}
function resolveProviderAccent(providerId: string): string {
const normalized = providerId.toLowerCase();
if (normalized.includes("github")) {
return "#8b5cf6";
}
if (normalized.includes("google")) {
return "#e5484d";
}
if (normalized.includes("ldap")) {
return "#14b8a6";
}
return "#2f80ff";
}