@@ -9,15 +9,25 @@ import { Strong, Text, TextLink } from "@tw/text";
99import { FormEvent , useCallback , useEffect , useRef , useState } from "react" ;
1010import { useNavigate } from "react-router" ;
1111import ThemeSwitch from "./ThemeSwitch" ;
12- import { RegisterUserDtoFromJSON } from "../api" ;
12+ import { Status } from "../api" ;
1313
1414export 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 ( ! / ^ h t t p s ? : \/ \/ / 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