Skip to content

Commit 8aa8cbe

Browse files
committed
Update registration and login components
1 parent 91d5415 commit 8aa8cbe

5 files changed

Lines changed: 530 additions & 316 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Changes
66

7+
- [#5](https://github.com/Phalcode/gamevault-frontend/issues/5) - Dynamically show or hide Basic Auth and SSO options on Login and Register pages based on server configuration
78
- [#6](https://github.com/Phalcode/gamevault-frontend/issues/6) - Use proper href links on game cards and community progress list for better accessibility (middle-click/right-click to open in new tab)
89
- [#9](https://github.com/Phalcode/gamevault-frontend/issues/9) - Fix search with special characters (apostrophes) causing blank page due to excessive history API calls (added debounce and duplicate URL check)
910

src/components/Login.tsx

Lines changed: 230 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,25 @@ import { Strong, Text, TextLink } from "@tw/text";
99
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
1010
import { useNavigate } from "react-router";
1111
import ThemeSwitch from "./ThemeSwitch";
12-
import { RegisterUserDtoFromJSON } from "../api";
12+
import { Status } from "../api";
1313

1414
export function Login() {
1515
const { loginBasic, loginWithTokens, loading, error } = useAuth();
1616
const navigate = useNavigate();
1717
const [server, setServer] = useState(window.location.origin);
18+
const [confirmedServer, setConfirmedServer] = useState<string | null>(null);
1819
const [username, setUsername] = useState("");
1920
const [password, setPassword] = useState("");
2021
const [useSso, setUseSso] = useState(false);
22+
const [serverStatus, setServerStatus] = useState<Status | null>(null);
23+
const [statusLoading, setStatusLoading] = useState(false);
24+
const [statusError, setStatusError] = useState(false);
25+
26+
const basicAuthAvailable =
27+
serverStatus?.available_authentication_methods?.includes("basic") ?? true;
28+
const ssoAvailable =
29+
serverStatus?.available_authentication_methods?.includes("sso") ?? true;
30+
const noAuthAvailable = !basicAuthAvailable && !ssoAvailable;
2131

2232
// Refs for focus trap
2333
const serverRef = useRef<HTMLInputElement | null>(null);
@@ -26,9 +36,11 @@ export function Login() {
2636
const submitRef = useRef<HTMLButtonElement | null>(null);
2737

2838
useEffect(() => {
29-
// Auto-focus server field on mount
30-
serverRef.current?.focus();
31-
}, []);
39+
// Auto-focus server field on mount if not confirmed
40+
if (!confirmedServer) {
41+
serverRef.current?.focus();
42+
}
43+
}, [confirmedServer]);
3244

3345
const normalizeServer = useCallback((raw: string) => {
3446
if (!raw) return raw;
@@ -42,12 +54,72 @@ export function Login() {
4254
return s;
4355
}, []);
4456

57+
useEffect(() => {
58+
if (!confirmedServer) {
59+
setServerStatus(null);
60+
setStatusError(false);
61+
return;
62+
}
63+
const normalized = normalizeServer(confirmedServer);
64+
if (!normalized) {
65+
setServerStatus(null);
66+
return;
67+
}
68+
69+
setStatusLoading(true);
70+
setStatusError(false);
71+
const controller = new AbortController();
72+
73+
(async () => {
74+
try {
75+
const res = await fetch(`${normalized}/api/status`, {
76+
signal: controller.signal,
77+
headers: { Accept: "application/json" },
78+
});
79+
if (res.ok) {
80+
const data = await res.json();
81+
setServerStatus(data);
82+
} else {
83+
setServerStatus(null);
84+
setStatusError(true);
85+
}
86+
} catch (e: any) {
87+
if (e.name !== "AbortError") {
88+
setServerStatus(null);
89+
setStatusError(true);
90+
}
91+
} finally {
92+
if (!controller.signal.aborted) {
93+
setStatusLoading(false);
94+
}
95+
}
96+
})();
97+
98+
return () => {
99+
controller.abort();
100+
};
101+
}, [confirmedServer, normalizeServer]);
102+
103+
useEffect(() => {
104+
if (serverStatus) {
105+
if (ssoAvailable && !basicAuthAvailable) {
106+
setUseSso(true);
107+
} else if (!ssoAvailable && basicAuthAvailable) {
108+
setUseSso(false);
109+
}
110+
}
111+
}, [serverStatus, ssoAvailable, basicAuthAvailable]);
112+
45113
// Parse SSO redirect style: {server}/access_token=...&refresh_token=...
46114
useEffect(() => {
47115
try {
48116
const loc = window.location;
49-
const search = loc.search.startsWith("?") ? loc.search.substring(1) : loc.search;
50-
const path = loc.pathname.startsWith("/") ? loc.pathname.slice(1) : loc.pathname;
117+
const search = loc.search.startsWith("?")
118+
? loc.search.substring(1)
119+
: loc.search;
120+
const path = loc.pathname.startsWith("/")
121+
? loc.pathname.slice(1)
122+
: loc.pathname;
51123
const hash = loc.hash.startsWith("#") ? loc.hash.slice(1) : loc.hash;
52124

53125
// Priority order: query string (?access_token=...), then path style, then hash fragment.
@@ -65,10 +137,13 @@ export function Login() {
65137
const base = window.location.origin; // assume same origin the user entered for SSO
66138
(async () => {
67139
try {
68-
await loginWithTokens(base, { access_token: access, refresh_token: refresh });
140+
await loginWithTokens(base, {
141+
access_token: access,
142+
refresh_token: refresh,
143+
});
69144
// Scrub sensitive tokens from URL: go to /login (or /library directly after navigation) without query/hash.
70145
const cleanUrl = base + "/login";
71-
window.history.replaceState({}, document.title, cleanUrl);
146+
window.history.replaceState({}, document.title, cleanUrl);
72147
navigate("/library", { replace: true });
73148
} catch {
74149
// ignore - context will show error
@@ -79,10 +154,24 @@ export function Login() {
79154
}
80155
}, [loginWithTokens, navigate]);
81156

157+
const handleContinue = (e: React.FormEvent) => {
158+
e.preventDefault();
159+
if (!server.trim()) return;
160+
let normalized = server.trim();
161+
if (!/^https?:\/\//i.test(normalized)) normalized = `https://${normalized}`;
162+
setServer(normalized);
163+
setConfirmedServer(normalized);
164+
};
165+
166+
const handleChangeServer = () => {
167+
setConfirmedServer(null);
168+
setServerStatus(null);
169+
};
170+
82171
const onSubmit = async (e: FormEvent) => {
83172
e.preventDefault();
84173
try {
85-
const normalized = normalizeServer(server);
174+
const normalized = normalizeServer(confirmedServer || server);
86175
if (useSso) {
87176
window.location.href = `${normalized}/api/auth/oauth2/login`;
88177
return;
@@ -130,89 +219,146 @@ export function Login() {
130219

131220
return (
132221
<form
133-
onSubmit={onSubmit}
222+
onSubmit={confirmedServer ? onSubmit : handleContinue}
134223
onKeyDown={handleTrapKey}
135224
className="grid w-full max-w-sm grid-cols-1 gap-8"
136225
>
137226
<div tabIndex={-1} aria-hidden="true">
138227
<Logo variant="text" className="w-full" height="h-full" />
139228
</div>
140229
<Heading tabIndex={-1}>Sign in to your account</Heading>
141-
<Field>
142-
<Label>Server</Label>
143-
<Input
144-
type="text"
145-
name="server"
146-
required
147-
value={server}
148-
onChange={(e) => setServer(e.target.value)}
149-
// Only trim whitespace on blur; do NOT auto-inject protocol into the visible field
150-
onBlur={() => setServer((s) => s.trim())}
151-
autoComplete="url"
152-
ref={serverRef}
153-
tabIndex={1}
154-
/>
155-
</Field>
156-
<Field>
157-
<Label>Username or Email</Label>
158-
<Input
159-
name="username"
160-
required
161-
autoComplete="username"
162-
value={username}
163-
onChange={(e) => setUsername(e.target.value)}
164-
ref={userRef}
165-
tabIndex={2}
166-
disabled={useSso}
167-
/>
168-
</Field>
169-
<Field>
170-
<Label>Password</Label>
171-
<Input
172-
type="password"
173-
name="password"
174-
required
175-
autoComplete="current-password"
176-
value={password}
177-
onChange={(e) => setPassword(e.target.value)}
178-
ref={passRef}
179-
tabIndex={3}
180-
disabled={useSso}
181-
/>
182-
</Field>
183-
<CheckboxField className="cursor-pointer select-none">
184-
<Checkbox
185-
id="login-sso"
186-
name="useSso"
187-
color="indigo"
188-
className="cursor-pointer"
189-
checked={useSso}
190-
onChange={(checked: boolean) => setUseSso(!!checked)}
191-
/>
192-
<Label htmlFor="login-sso" className="cursor-pointer">
193-
Login with SSO
194-
</Label>
195-
</CheckboxField>
196-
{error && (
197-
<div className="text-sm text-red-500 -mt-4" role="alert">
198-
{error}
199-
</div>
230+
231+
{!confirmedServer && (
232+
<>
233+
<Field>
234+
<Label>Server</Label>
235+
<Input
236+
type="text"
237+
name="server"
238+
required
239+
value={server}
240+
onChange={(e) => setServer(e.target.value)}
241+
// Only trim whitespace on blur; do NOT auto-inject protocol into the visible field
242+
onBlur={() => setServer((s) => s.trim())}
243+
autoComplete="url"
244+
ref={serverRef}
245+
tabIndex={1}
246+
autoFocus
247+
/>
248+
</Field>
249+
<Button type="submit" className="w-full">
250+
Continue
251+
</Button>
252+
</>
200253
)}
201-
<Button
202-
type="submit"
203-
className="w-full"
204-
disabled={loading}
205-
tabIndex={4}
206-
ref={submitRef}
207-
>
208-
{loading
209-
? useSso
210-
? "Preparing SSO…"
211-
: "Authenticating…"
212-
: useSso
213-
? "Continue with SSO"
214-
: "Login"}
215-
</Button>
254+
255+
{confirmedServer && (
256+
<>
257+
<Field>
258+
<Label>Server</Label>
259+
<div data-slot="control" className="flex gap-2">
260+
<Input
261+
name="server_display"
262+
value={confirmedServer}
263+
disabled
264+
className="flex-1"
265+
/>
266+
<Button
267+
type="button"
268+
onClick={handleChangeServer}
269+
className="shrink-0 bg-gray-200 text-gray-800 hover:bg-gray-300"
270+
>
271+
Change
272+
</Button>
273+
</div>
274+
</Field>
275+
276+
{statusLoading && <Text>Connecting to server...</Text>}
277+
278+
{statusError && (
279+
<Text className="text-xs text-rose-400 -mt-4">
280+
Failed to connect to server. Please check the URL.
281+
</Text>
282+
)}
283+
284+
{!statusLoading && !statusError && (
285+
<>
286+
{basicAuthAvailable && !useSso && (
287+
<Field>
288+
<Label>Username or Email</Label>
289+
<Input
290+
name="username"
291+
required
292+
autoComplete="username"
293+
value={username}
294+
onChange={(e) => setUsername(e.target.value)}
295+
ref={userRef}
296+
tabIndex={2}
297+
/>
298+
</Field>
299+
)}
300+
{basicAuthAvailable && !useSso && (
301+
<Field>
302+
<Label>Password</Label>
303+
<Input
304+
type="password"
305+
name="password"
306+
required
307+
autoComplete="current-password"
308+
value={password}
309+
onChange={(e) => setPassword(e.target.value)}
310+
ref={passRef}
311+
tabIndex={3}
312+
/>
313+
</Field>
314+
)}
315+
{ssoAvailable && basicAuthAvailable && (
316+
<CheckboxField className="cursor-pointer select-none">
317+
<Checkbox
318+
id="login-sso"
319+
name="useSso"
320+
color="indigo"
321+
className="cursor-pointer"
322+
checked={useSso}
323+
onChange={(checked: boolean) => setUseSso(!!checked)}
324+
/>
325+
<Label htmlFor="login-sso" className="cursor-pointer">
326+
Login with SSO
327+
</Label>
328+
</CheckboxField>
329+
)}
330+
{noAuthAvailable && (
331+
<div className="text-sm text-rose-500" role="alert">
332+
No authentication methods are currently available on this server.
333+
</div>
334+
)}
335+
{error && (
336+
<div className="text-sm text-red-500 -mt-4" role="alert">
337+
{error}
338+
</div>
339+
)}
340+
{!noAuthAvailable && (
341+
<Button
342+
type="submit"
343+
className="w-full"
344+
disabled={loading}
345+
tabIndex={4}
346+
ref={submitRef}
347+
>
348+
{loading
349+
? useSso
350+
? "Preparing SSO…"
351+
: "Authenticating…"
352+
: useSso
353+
? "Continue with SSO"
354+
: "Login"}
355+
</Button>
356+
)}
357+
</>
358+
)}
359+
</>
360+
)}
361+
216362
<Text tabIndex={-1} aria-hidden="true">
217363
Don’t have an account? {""}
218364
<TextLink href="/register" tabIndex={-1} aria-hidden="true">

0 commit comments

Comments
 (0)