11"use client" ;
22
3- import { useState , useEffect } from "react" ;
3+ import { useState , useRef , useEffect } from "react" ;
44import { useSolidAuth } from "@ldo/solid-react" ;
55import { useRouter } from "next/navigation" ;
66import Image from "next/image" ;
77import Button from "./shared/Button" ;
8+ import { ChevronDownIcon } from "@heroicons/react/24/outline" ;
89
9- const OIDC_ISSUERS = [
10+ const PRESET_ISSUERS = [
1011 { label : "Solid Community" , value : "https://solidcommunity.net/" } ,
1112 { label : "Inrupt" , value : "https://login.inrupt.com" } ,
12- { label : "Local CSS (ACP)" , value : "http://localhost:3000/" } ,
1313] as const ;
1414
1515export default function LoginPage ( ) {
1616 const { session, login } = useSolidAuth ( ) ;
1717 const router = useRouter ( ) ;
18- const [ selectedIssuer , setSelectedIssuer ] = useState < string > (
19- process . env . NEXT_PUBLIC_OIDC_ISSUER || OIDC_ISSUERS [ 0 ] . value
18+ const [ issuerInput , setIssuerInput ] = useState < string > (
19+ process . env . NEXT_PUBLIC_OIDC_ISSUER || ""
2020 ) ;
2121 const [ isLoading , setIsLoading ] = useState ( false ) ;
22+ const [ showDropdown , setShowDropdown ] = useState ( false ) ;
23+ const [ error , setError ] = useState < string | null > ( null ) ;
24+ const inputRef = useRef < HTMLInputElement > ( null ) ;
25+ const dropdownRef = useRef < HTMLDivElement > ( null ) ;
2226
2327 // Redirect to home if already authenticated
2428 useEffect ( ( ) => {
@@ -32,16 +36,85 @@ export default function LoginPage() {
3236 return null ;
3337 }
3438
39+ // Close dropdown when clicking outside
40+ useEffect ( ( ) => {
41+ const handleClickOutside = ( event : MouseEvent ) => {
42+ if (
43+ dropdownRef . current &&
44+ ! dropdownRef . current . contains ( event . target as Node ) &&
45+ inputRef . current &&
46+ ! inputRef . current . contains ( event . target as Node )
47+ ) {
48+ setShowDropdown ( false ) ;
49+ }
50+ } ;
51+
52+ if ( showDropdown ) {
53+ document . addEventListener ( "mousedown" , handleClickOutside ) ;
54+ return ( ) => document . removeEventListener ( "mousedown" , handleClickOutside ) ;
55+ }
56+ } , [ showDropdown ] ) ;
57+
58+ const validateIssuerUrl = ( url : string ) : boolean => {
59+ if ( ! url . trim ( ) ) {
60+ setError ( "Please enter a Solid Identity Provider URL" ) ;
61+ return false ;
62+ }
63+
64+ try {
65+ const parsedUrl = new URL ( url ) ;
66+ if ( parsedUrl . protocol !== "http:" && parsedUrl . protocol !== "https:" ) {
67+ setError ( "URL must start with http:// or https://" ) ;
68+ return false ;
69+ }
70+ } catch {
71+ setError ( "Please enter a valid URL" ) ;
72+ return false ;
73+ }
74+
75+ setError ( null ) ;
76+ return true ;
77+ } ;
78+
3579 const handleLogin = async ( ) => {
80+ const trimmedIssuer = issuerInput . trim ( ) ;
81+ if ( ! validateIssuerUrl ( trimmedIssuer ) ) {
82+ return ;
83+ }
84+
3685 setIsLoading ( true ) ;
3786 try {
38- await login ( selectedIssuer ) ;
87+ await login ( trimmedIssuer ) ;
3988 } catch ( error ) {
4089 console . error ( "Login failed:" , error ) ;
4190 setIsLoading ( false ) ;
4291 }
4392 } ;
4493
94+ const handleIssuerSelect = ( value : string ) => {
95+ setIssuerInput ( value ) ;
96+ setShowDropdown ( false ) ;
97+ setError ( null ) ;
98+ inputRef . current ?. focus ( ) ;
99+ } ;
100+
101+ const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
102+ setIssuerInput ( e . target . value ) ;
103+ if ( error ) {
104+ setError ( null ) ;
105+ }
106+ } ;
107+
108+ // Filter preset issuers based on input
109+ const filteredIssuers = PRESET_ISSUERS . filter ( ( issuer ) => {
110+ if ( ! issuerInput . trim ( ) ) return true ;
111+ const query = issuerInput . toLowerCase ( ) ;
112+ return (
113+ issuer . label . toLowerCase ( ) . includes ( query ) ||
114+ issuer . value . toLowerCase ( ) . includes ( query )
115+ ) ;
116+ } ) ;
117+
45118 return (
46119 < main className = "flex min-h-screen bg-white" role = "main" aria-label = "Sign in page" >
47120 { /* Left side - Logo and branding */ }
@@ -102,34 +175,82 @@ export default function LoginPage() {
102175 aria-label = "Sign in form"
103176 noValidate
104177 >
105- { /* Identity Provider Selection */ }
178+ { /* Identity Provider Input */ }
106179 < div >
107180 < label
108181 htmlFor = "oidc-issuer"
109182 className = "mb-2 block text-sm font-medium text-black"
110183 >
111184 Solid Identity Provider
112185 </ label >
113- < select
114- id = "oidc-issuer"
115- name = "oidc-issuer"
116- value = { selectedIssuer }
117- onChange = { ( e ) => setSelectedIssuer ( e . target . value ) }
118- className = "h-12 w-full cursor-pointer rounded-md border border-gray-300 bg-white px-4 text-black focus:border-[#7B42F6] focus:outline-none focus:ring-1 focus:ring-[#7B42F6] disabled:cursor-not-allowed disabled:opacity-50"
119- disabled = { isLoading }
120- required
121- aria-required = "true"
122- aria-label = "Select Solid Identity Provider"
123- aria-describedby = "oidc-issuer-description"
124- >
125- { OIDC_ISSUERS . map ( ( issuer ) => (
126- < option key = { issuer . value } value = { issuer . value } >
127- { issuer . label }
128- </ option >
129- ) ) }
130- </ select >
186+ < div className = "relative" >
187+ < input
188+ ref = { inputRef }
189+ id = "oidc-issuer"
190+ name = "oidc-issuer"
191+ type = "text"
192+ value = { issuerInput }
193+ onChange = { handleInputChange }
194+ onFocus = { ( ) => setShowDropdown ( true ) }
195+ placeholder = "Enter your provider URL or select from the list"
196+ className = { `h-12 w-full rounded-md border bg-white px-4 pr-10 text-black placeholder:text-gray-500 focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 ${
197+ error
198+ ? "border-red-300 focus:border-red-500 focus:ring-red-500"
199+ : "border-gray-300 focus:border-[#7B42F6] focus:ring-[#7B42F6]"
200+ } `}
201+ disabled = { isLoading }
202+ required
203+ aria-required = "true"
204+ aria-label = "Enter or select Solid Identity Provider"
205+ aria-describedby = { error ? "oidc-issuer-error" : "oidc-issuer-description" }
206+ aria-invalid = { ! ! error }
207+ autoComplete = "url"
208+ />
209+ < button
210+ type = "button"
211+ onClick = { ( ) => setShowDropdown ( ! showDropdown ) }
212+ className = "absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
213+ aria-label = "Show provider options"
214+ tabIndex = { - 1 }
215+ >
216+ < ChevronDownIcon className = { `h-5 w-5 transition-transform ${ showDropdown ? "rotate-180" : "" } ` } />
217+ </ button >
218+
219+ { /* Dropdown with preset options */ }
220+ { showDropdown && filteredIssuers . length > 0 && (
221+ < div
222+ ref = { dropdownRef }
223+ className = "absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto"
224+ role = "listbox"
225+ aria-label = "Preset identity providers"
226+ >
227+ { filteredIssuers . map ( ( issuer ) => (
228+ < button
229+ key = { issuer . value }
230+ type = "button"
231+ onClick = { ( ) => handleIssuerSelect ( issuer . value ) }
232+ className = "w-full px-4 py-3 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
233+ role = "option"
234+ aria-selected = { issuerInput === issuer . value }
235+ >
236+ < div className = "text-sm font-medium text-gray-900" >
237+ { issuer . label }
238+ </ div >
239+ < div className = "text-xs text-gray-500" >
240+ { issuer . value }
241+ </ div >
242+ </ button >
243+ ) ) }
244+ </ div >
245+ ) }
246+ </ div >
247+ { error && (
248+ < p id = "oidc-issuer-error" className = "mt-1 text-xs text-red-600" role = "alert" >
249+ { error }
250+ </ p >
251+ ) }
131252 < p id = "oidc-issuer-description" className = "sr-only" >
132- Choose your Solid Identity Provider to sign in
253+ Enter your Solid Identity Provider URL or select from the preset options
133254 </ p >
134255 </ div >
135256
@@ -151,4 +272,3 @@ export default function LoginPage() {
151272 </ main >
152273 ) ;
153274}
154-
0 commit comments