Skip to content

Commit f16f802

Browse files
Merge pull request #34 from solid/copilot/enable-custom-server-login
Enable custom IDP login with reusable UrlCombobox component
2 parents 5294fa4 + da11ef9 commit f16f802

5 files changed

Lines changed: 413 additions & 219 deletions

File tree

app/components/LoginPage.tsx

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,52 @@ import { useState } from "react";
44
import { login } from "@inrupt/solid-client-authn-browser";
55
import Image from "next/image";
66
import Button from "./shared/Button";
7+
import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox";
78

8-
const OIDC_ISSUERS = [
9-
{ label: "Solid Community", value: "https://solidcommunity.net/" },
10-
{ label: "Inrupt", value: "https://login.inrupt.com" },
11-
{ label: "Local CSS (ACP)", value: "http://localhost:3000/" },
12-
] as const;
9+
const PRESET_ISSUERS: ComboboxOption[] = [
10+
{ label: "Solid Community", value: "https://solidcommunity.net/", secondaryLabel: "https://solidcommunity.net/" },
11+
{ label: "Inrupt", value: "https://login.inrupt.com", secondaryLabel: "https://login.inrupt.com" },
12+
];
1313

1414
export 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 [error, setError] = useState<string | null>(null);
20+
21+
const validateIssuerUrl = (url: string): boolean => {
22+
if (!url.trim()) {
23+
setError("Please enter a Solid Identity Provider URL");
24+
return false;
25+
}
26+
27+
try {
28+
const parsedUrl = new URL(url);
29+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
30+
setError("URL must start with http:// or https://");
31+
return false;
32+
}
33+
} catch {
34+
setError("Please enter a valid URL");
35+
return false;
36+
}
37+
38+
setError(null);
39+
return true;
40+
};
1941

2042
const handleLogin = async () => {
43+
const trimmedIssuer = issuerInput.trim();
44+
if (!validateIssuerUrl(trimmedIssuer)) {
45+
return;
46+
}
47+
2148
setIsLoading(true);
2249
try {
2350
const baseUrl = window.location.origin + window.location.pathname;
2451
await login({
25-
oidcIssuer: selectedIssuer,
52+
oidcIssuer: trimmedIssuer,
2653
clientName: "Solid File Manager",
2754
redirectUrl: baseUrl,
2855
});
@@ -32,6 +59,13 @@ export default function LoginPage() {
3259
}
3360
};
3461

62+
const handleIssuerChange = (value: string) => {
63+
setIssuerInput(value);
64+
if (error) {
65+
setError(null);
66+
}
67+
};
68+
3569
return (
3670
<main className="flex min-h-screen bg-white" role="main" aria-label="Sign in page">
3771
{/* Left side - Logo and branding */}
@@ -92,36 +126,18 @@ export default function LoginPage() {
92126
aria-label="Sign in form"
93127
noValidate
94128
>
95-
{/* Identity Provider Selection */}
96-
<div>
97-
<label
98-
htmlFor="oidc-issuer"
99-
className="mb-2 block text-sm font-medium text-black"
100-
>
101-
Solid Identity Provider
102-
</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>
121-
<p id="oidc-issuer-description" className="sr-only">
122-
Choose your Solid Identity Provider to sign in
123-
</p>
124-
</div>
129+
{/* Identity Provider Input */}
130+
<UrlCombobox
131+
id="oidc-issuer"
132+
label="Solid Identity Provider"
133+
value={issuerInput}
134+
onChange={handleIssuerChange}
135+
options={PRESET_ISSUERS}
136+
placeholder="Enter your provider URL or select from the list"
137+
error={error || undefined}
138+
disabled={isLoading}
139+
aria-label="Enter or select Solid Identity Provider"
140+
/>
125141

126142
{/* Action button */}
127143
<div className="flex items-center justify-end pt-4">

app/components/ShareDialog.tsx

Lines changed: 35 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"use client";
22

3-
import { useState, useEffect, useRef } from "react";
3+
import { useState, useEffect, useMemo } from "react";
44
import Modal from "./shared/Modal";
55
import Button from "./shared/Button";
6-
import Input from "./shared/Input";
6+
import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox";
77
import { FileItemData } from "./FileItem";
88
import { fetchUserContacts, Contact } from "../lib/helpers/contactUtils";
99
import { fetchAndParseProfile, extractNameAndEmail } from "../lib/helpers/profileUtils";
@@ -35,16 +35,12 @@ export default function ShareDialog({
3535
const [webIdInput, setWebIdInput] = useState("");
3636
const [contacts, setContacts] = useState<Contact[]>([]);
3737
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
38-
const [showDropdown, setShowDropdown] = useState(false);
39-
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([]);
4038
const [selectedAccessLevel, setSelectedAccessLevel] = useState<AccessLevel>("Editor");
4139
const [peopleChips, setPeopleChips] = useState<PersonChip[]>([]);
4240
const [isAddingWebId, setIsAddingWebId] = useState(false);
4341
const [isSharing, setIsSharing] = useState(false);
4442
const [accessList, setAccessList] = useState<Array<{ webId: string; accessModes: string[] }> | null>(null);
4543
const [isLoadingAccessList, setIsLoadingAccessList] = useState(false);
46-
const inputRef = useRef<HTMLInputElement>(null);
47-
const dropdownRef = useRef<HTMLDivElement>(null);
4844

4945
// Fetch contacts and access list when dialog opens
5046
useEffect(() => {
@@ -75,64 +71,34 @@ export default function ShareDialog({
7571
} else {
7672
// Reset state when dialog closes
7773
setWebIdInput("");
78-
setShowDropdown(false);
79-
setFilteredContacts([]);
8074
setSelectedAccessLevel("Editor");
8175
setPeopleChips([]);
8276
setAccessList(null);
8377
}
8478
}, [isOpen, file]);
8579

86-
// Filter contacts based on input
87-
useEffect(() => {
88-
if (!webIdInput.trim()) {
89-
// When input is empty, show all contacts
90-
setFilteredContacts(contacts);
91-
return;
92-
}
93-
94-
const query = webIdInput.toLowerCase().trim();
95-
const filtered = contacts.filter((contact) => {
96-
const nameMatch = contact.name?.toLowerCase().includes(query);
97-
const emailMatch = contact.email?.toLowerCase().includes(query);
98-
const webIdMatch = contact.webId.toLowerCase().includes(query);
99-
return nameMatch || emailMatch || webIdMatch;
100-
});
101-
102-
setFilteredContacts(filtered);
103-
}, [webIdInput, contacts]);
104-
105-
// Close dropdown when clicking outside
106-
useEffect(() => {
107-
const handleClickOutside = (event: MouseEvent) => {
108-
if (
109-
dropdownRef.current &&
110-
!dropdownRef.current.contains(event.target as Node) &&
111-
inputRef.current &&
112-
!inputRef.current.contains(event.target as Node)
113-
) {
114-
setShowDropdown(false);
115-
}
116-
};
117-
118-
if (showDropdown) {
119-
document.addEventListener("mousedown", handleClickOutside);
120-
return () => document.removeEventListener("mousedown", handleClickOutside);
121-
}
122-
}, [showDropdown]);
123-
124-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
125-
setWebIdInput(e.target.value);
126-
};
80+
// Convert contacts to ComboboxOptions
81+
const contactOptions: ComboboxOption[] = useMemo(() => {
82+
return contacts.map((contact) => ({
83+
label: contact.name || contact.email || contact.webId,
84+
value: contact.webId,
85+
secondaryLabel: contact.email && contact.name ? contact.email : undefined,
86+
icon: (
87+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
88+
{contact.name ? (
89+
<span className="text-sm font-medium text-gray-700">
90+
{contact.name.charAt(0).toUpperCase()}
91+
</span>
92+
) : (
93+
<UserIcon className="h-5 w-5 text-gray-500" />
94+
)}
95+
</div>
96+
),
97+
}));
98+
}, [contacts]);
12799

128-
const handleContactSelect = (contact: Contact) => {
129-
// Populate the input with the selected contact's WebID
130-
setWebIdInput(contact.webId);
131-
setShowDropdown(false);
132-
// Focus back on input so user can press Enter to add
133-
setTimeout(() => {
134-
inputRef.current?.focus();
135-
}, 0);
100+
const handleWebIdChange = (value: string) => {
101+
setWebIdInput(value);
136102
};
137103

138104
const handleAddWebId = async () => {
@@ -144,7 +110,6 @@ export default function ShareDialog({
144110
// Check if person is already added
145111
if (peopleChips.some((p) => p.webId === webId)) {
146112
setWebIdInput("");
147-
setShowDropdown(false);
148113
return;
149114
}
150115

@@ -171,7 +136,6 @@ export default function ShareDialog({
171136
},
172137
]);
173138
setWebIdInput("");
174-
setShowDropdown(false);
175139
} catch (error) {
176140
console.error("Failed to fetch profile for WebID:", error);
177141
// Add with just WebID if profile fetch fails
@@ -184,7 +148,6 @@ export default function ShareDialog({
184148
},
185149
]);
186150
setWebIdInput("");
187-
setShowDropdown(false);
188151
} finally {
189152
setIsAddingWebId(false);
190153
}
@@ -294,65 +257,18 @@ export default function ShareDialog({
294257
</div>
295258
)}
296259

297-
<div className="relative">
298-
<Input
299-
ref={inputRef}
300-
type="text"
301-
placeholder="Add a WebID"
302-
value={webIdInput}
303-
onChange={handleInputChange}
304-
onFocus={() => {
305-
// Show all contacts when input is focused
306-
if (contacts.length > 0) {
307-
setShowDropdown(true);
308-
}
309-
}}
310-
onKeyDown={(e) => {
311-
if (e.key === "Enter" && webIdInput.trim() && !isAddingWebId) {
312-
e.preventDefault();
313-
handleAddWebId();
314-
}
315-
}}
316-
leftIcon={<MagnifyingGlassIcon className="h-5 w-5" />}
317-
className="w-full"
318-
disabled={isAddingWebId}
319-
/>
320-
{showDropdown && filteredContacts.length > 0 && (
321-
<div
322-
ref={dropdownRef}
323-
className="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto"
324-
>
325-
{filteredContacts.map((contact) => (
326-
<button
327-
key={contact.webId}
328-
type="button"
329-
onClick={() => handleContactSelect(contact)}
330-
className="w-full px-4 py-2 text-left hover:bg-gray-100 flex items-center gap-3"
331-
>
332-
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
333-
{contact.name ? (
334-
<span className="text-sm font-medium text-gray-700">
335-
{contact.name.charAt(0).toUpperCase()}
336-
</span>
337-
) : (
338-
<UserIcon className="h-5 w-5 text-gray-500" />
339-
)}
340-
</div>
341-
<div className="flex-1 min-w-0">
342-
<div className="text-sm font-medium text-gray-900 truncate">
343-
{contact.name || contact.email || contact.webId}
344-
</div>
345-
{contact.email && contact.name && (
346-
<div className="text-xs text-gray-500 truncate">
347-
{contact.email}
348-
</div>
349-
)}
350-
</div>
351-
</button>
352-
))}
353-
</div>
354-
)}
355-
</div>
260+
<UrlCombobox
261+
value={webIdInput}
262+
onChange={handleWebIdChange}
263+
onSubmit={handleAddWebId}
264+
options={contactOptions}
265+
placeholder="Add a WebID"
266+
disabled={isAddingWebId}
267+
leftIcon={<MagnifyingGlassIcon className="h-5 w-5" />}
268+
showChevron={false}
269+
aria-label="Add a WebID"
270+
inputClassName="h-9"
271+
/>
356272
</div>
357273

358274
{/* General access section */}

0 commit comments

Comments
 (0)