11"use client" ;
22
3- import { useState } from "react" ;
3+ import { useState , useRef , useEffect } from "react" ;
44import { login } from "@inrupt/solid-client-authn-browser" ;
55import Image from "next/image" ;
66import Button from "./shared/Button" ;
7+ import { ChevronDownIcon } from "@heroicons/react/24/outline" ;
78
8- const OIDC_ISSUERS = [
9+ const PRESET_ISSUERS = [
910 { label : "Solid Community" , value : "https://solidcommunity.net/" } ,
1011 { label : "Inrupt" , value : "https://login.inrupt.com" } ,
11- { label : "Local CSS (ACP)" , value : "http://localhost:3000/" } ,
1212] as const ;
1313
1414export default function LoginPage ( ) {
15- const [ selectedIssuer , setSelectedIssuer ] = useState < string > (
16- process . env . NEXT_PUBLIC_OIDC_ISSUER || OIDC_ISSUERS [ 0 ] . value
15+ const [ issuerInput , setIssuerInput ] = useState < string > (
16+ process . env . NEXT_PUBLIC_OIDC_ISSUER || ""
1717 ) ;
1818 const [ isLoading , setIsLoading ] = useState ( false ) ;
19+ const [ showDropdown , setShowDropdown ] = useState ( false ) ;
20+ const [ error , setError ] = useState < string | null > ( null ) ;
21+ const inputRef = useRef < HTMLInputElement > ( null ) ;
22+ const dropdownRef = useRef < HTMLDivElement > ( null ) ;
23+
24+ // Close dropdown when clicking outside
25+ useEffect ( ( ) => {
26+ const handleClickOutside = ( event : MouseEvent ) => {
27+ if (
28+ dropdownRef . current &&
29+ ! dropdownRef . current . contains ( event . target as Node ) &&
30+ inputRef . current &&
31+ ! inputRef . current . contains ( event . target as Node )
32+ ) {
33+ setShowDropdown ( false ) ;
34+ }
35+ } ;
36+
37+ if ( showDropdown ) {
38+ document . addEventListener ( "mousedown" , handleClickOutside ) ;
39+ return ( ) => document . removeEventListener ( "mousedown" , handleClickOutside ) ;
40+ }
41+ } , [ showDropdown ] ) ;
42+
43+ const validateIssuerUrl = ( url : string ) : boolean => {
44+ if ( ! url . trim ( ) ) {
45+ setError ( "Please enter a Solid Identity Provider URL" ) ;
46+ return false ;
47+ }
48+
49+ try {
50+ const parsedUrl = new URL ( url ) ;
51+ if ( parsedUrl . protocol !== "http:" && parsedUrl . protocol !== "https:" ) {
52+ setError ( "URL must start with http:// or https://" ) ;
53+ return false ;
54+ }
55+ } catch {
56+ setError ( "Please enter a valid URL" ) ;
57+ return false ;
58+ }
59+
60+ setError ( null ) ;
61+ return true ;
62+ } ;
1963
2064 const handleLogin = async ( ) => {
65+ const trimmedIssuer = issuerInput . trim ( ) ;
66+ if ( ! validateIssuerUrl ( trimmedIssuer ) ) {
67+ return ;
68+ }
69+
2170 setIsLoading ( true ) ;
2271 try {
2372 const baseUrl = window . location . origin + window . location . pathname ;
2473 await login ( {
25- oidcIssuer : selectedIssuer ,
74+ oidcIssuer : trimmedIssuer ,
2675 clientName : "Solid File Manager" ,
2776 redirectUrl : baseUrl ,
2877 } ) ;
@@ -32,6 +81,30 @@ export default function LoginPage() {
3281 }
3382 } ;
3483
84+ const handleIssuerSelect = ( value : string ) => {
85+ setIssuerInput ( value ) ;
86+ setShowDropdown ( false ) ;
87+ setError ( null ) ;
88+ inputRef . current ?. focus ( ) ;
89+ } ;
90+
91+ const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
92+ setIssuerInput ( e . target . value ) ;
93+ if ( error ) {
94+ setError ( null ) ;
95+ }
96+ } ;
97+
98+ // Filter preset issuers based on input
99+ const filteredIssuers = PRESET_ISSUERS . filter ( ( issuer ) => {
100+ if ( ! issuerInput . trim ( ) ) return true ;
101+ const query = issuerInput . toLowerCase ( ) ;
102+ return (
103+ issuer . label . toLowerCase ( ) . includes ( query ) ||
104+ issuer . value . toLowerCase ( ) . includes ( query )
105+ ) ;
106+ } ) ;
107+
35108 return (
36109 < main className = "flex min-h-screen bg-white" role = "main" aria-label = "Sign in page" >
37110 { /* Left side - Logo and branding */ }
@@ -92,34 +165,82 @@ export default function LoginPage() {
92165 aria-label = "Sign in form"
93166 noValidate
94167 >
95- { /* Identity Provider Selection */ }
168+ { /* Identity Provider Input */ }
96169 < div >
97170 < label
98171 htmlFor = "oidc-issuer"
99172 className = "mb-2 block text-sm font-medium text-black"
100173 >
101174 Solid Identity Provider
102175 </ label >
103- < select
104- id = "oidc-issuer"
105- name = "oidc-issuer"
106- value = { selectedIssuer }
107- onChange = { ( e ) => setSelectedIssuer ( e . target . value ) }
108- 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"
109- disabled = { isLoading }
110- required
111- aria-required = "true"
112- aria-label = "Select Solid Identity Provider"
113- aria-describedby = "oidc-issuer-description"
114- >
115- { OIDC_ISSUERS . map ( ( issuer ) => (
116- < option key = { issuer . value } value = { issuer . value } >
117- { issuer . label }
118- </ option >
119- ) ) }
120- </ select >
176+ < div className = "relative" >
177+ < input
178+ ref = { inputRef }
179+ id = "oidc-issuer"
180+ name = "oidc-issuer"
181+ type = "text"
182+ value = { issuerInput }
183+ onChange = { handleInputChange }
184+ onFocus = { ( ) => setShowDropdown ( true ) }
185+ placeholder = "Enter your provider URL or select from the list"
186+ 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 ${
187+ error
188+ ? "border-red-300 focus:border-red-500 focus:ring-red-500"
189+ : "border-gray-300 focus:border-[#7B42F6] focus:ring-[#7B42F6]"
190+ } `}
191+ disabled = { isLoading }
192+ required
193+ aria-required = "true"
194+ aria-label = "Enter or select Solid Identity Provider"
195+ aria-describedby = { error ? "oidc-issuer-error" : "oidc-issuer-description" }
196+ aria-invalid = { ! ! error }
197+ autoComplete = "url"
198+ />
199+ < button
200+ type = "button"
201+ onClick = { ( ) => setShowDropdown ( ! showDropdown ) }
202+ className = "absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
203+ aria-label = "Show provider options"
204+ tabIndex = { - 1 }
205+ >
206+ < ChevronDownIcon className = { `h-5 w-5 transition-transform ${ showDropdown ? "rotate-180" : "" } ` } />
207+ </ button >
208+
209+ { /* Dropdown with preset options */ }
210+ { showDropdown && filteredIssuers . length > 0 && (
211+ < div
212+ ref = { dropdownRef }
213+ className = "absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto"
214+ role = "listbox"
215+ aria-label = "Preset identity providers"
216+ >
217+ { filteredIssuers . map ( ( issuer ) => (
218+ < button
219+ key = { issuer . value }
220+ type = "button"
221+ onClick = { ( ) => handleIssuerSelect ( issuer . value ) }
222+ className = "w-full px-4 py-3 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
223+ role = "option"
224+ aria-selected = { issuerInput === issuer . value }
225+ >
226+ < div className = "text-sm font-medium text-gray-900" >
227+ { issuer . label }
228+ </ div >
229+ < div className = "text-xs text-gray-500" >
230+ { issuer . value }
231+ </ div >
232+ </ button >
233+ ) ) }
234+ </ div >
235+ ) }
236+ </ div >
237+ { error && (
238+ < p id = "oidc-issuer-error" className = "mt-1 text-xs text-red-600" role = "alert" >
239+ { error }
240+ </ p >
241+ ) }
121242 < p id = "oidc-issuer-description" className = "sr-only" >
122- Choose your Solid Identity Provider to sign in
243+ Enter your Solid Identity Provider URL or select from the preset options
123244 </ p >
124245 </ div >
125246
0 commit comments