Compare commits

...

2 commits

Author SHA1 Message Date
e0122c2c6b Merge pull request 'complete feature' (#6) from feature/5-finish-frontend-passport-ui into main
Some checks failed
Podman Rootless Demo / test-backend (push) Has been skipped
Podman Rootless Demo / test-frontend (push) Has been skipped
Podman Rootless Demo / build-backend (push) Has been skipped
Podman Rootless Demo / build-frontend (push) Failing after 5m19s
Podman Rootless Demo / deploy-prod (push) Has been skipped
Reviewed-on: #6
2025-11-23 13:42:37 -05:00
8b13aa45fe complete feature
Some checks failed
Podman Rootless Demo / test-backend (push) Has been skipped
Podman Rootless Demo / test-frontend (push) Has been skipped
Podman Rootless Demo / build-backend (push) Has been skipped
Podman Rootless Demo / build-frontend (push) Failing after 5m52s
Podman Rootless Demo / deploy-prod (push) Has been skipped
Podman Rootless Demo / test-backend (pull_request) Has been skipped
Podman Rootless Demo / test-frontend (pull_request) Has been skipped
Podman Rootless Demo / build-backend (pull_request) Has been skipped
Podman Rootless Demo / build-frontend (pull_request) Failing after 5m15s
Podman Rootless Demo / deploy-prod (pull_request) Has been skipped
2025-11-23 13:41:09 -05:00
2 changed files with 638 additions and 0 deletions

View file

@ -0,0 +1,173 @@
'use client';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { PassportBrowserIO } from '@/lib/wasm-browser';
interface ExportPassportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
passportId: string | null;
}
export function ExportPassportDialog({
open,
onOpenChange,
passportId
}: ExportPassportDialogProps) {
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
filename: ''
});
const [isExporting, setIsExporting] = useState(false);
const handleFormChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[field]: e.target.value
}));
};
const handleExportSubmit = async () => {
if (!passportId) {
alert('No passport found to export');
return;
}
// Validate form
if (!formData.password) {
alert('Password is required');
return;
}
if (formData.password !== formData.confirmPassword) {
alert('Passwords do not match');
return;
}
if (!formData.filename) {
alert('Filename is required');
return;
}
// Ensure filename has .spf extension
let filename = formData.filename;
if (!filename.toLowerCase().endsWith('.spf')) {
filename += '.spf';
}
setIsExporting(true);
try {
// Export the passport using browser I/O operations
await PassportBrowserIO.exportPassport(passportId, formData.password, filename);
// Reset form and close dialog
setFormData({
password: '',
confirmPassword: '',
filename: ''
});
onOpenChange(false);
} catch (error) {
console.error('Failed to export passport:', error);
alert('Failed to export passport. Please try again.');
} finally {
setIsExporting(false);
}
};
const handleClose = () => {
// Reset form when closing
setFormData({
password: '',
confirmPassword: '',
filename: ''
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export Passport</DialogTitle>
<DialogDescription>
Enter a password to encrypt the exported passport file and choose a filename.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password *
</label>
<Input
id="password"
type="password"
placeholder="Enter password"
value={formData.password}
onChange={handleFormChange('password')}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
Confirm Password *
</label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm password"
value={formData.confirmPassword}
onChange={handleFormChange('confirmPassword')}
required
/>
{formData.password !== formData.confirmPassword && formData.confirmPassword && (
<p className="text-sm text-red-600">Passwords do not match</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="filename" className="text-sm font-medium">
Filename *
</label>
<Input
id="filename"
placeholder="Enter filename"
value={formData.filename}
onChange={handleFormChange('filename')}
required
/>
<p className="text-xs text-gray-500">
File will be saved with .spf extension
</p>
</div>
</div>
<DialogFooter>
<div className="flex justify-end space-x-2 w-full">
<Button variant="outline" onClick={handleClose} disabled={isExporting}>
Cancel
</Button>
<Button
onClick={handleExportSubmit}
disabled={
isExporting ||
!formData.password ||
!formData.confirmPassword ||
!formData.filename ||
formData.password !== formData.confirmPassword
}
>
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,465 @@
'use client';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { UserIdentity, UserProfile, UserPreferences } from '@/lib/auth/types';
interface UserProfileDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
profile?: UserProfile | null;
onSubmit: (identity: UserIdentity, preferences: UserPreferences, showDateOfBirth: boolean, hubDid?: string) => Promise<void>;
isLoading: boolean;
}
export function UserProfileDialog({
open,
onOpenChange,
mode,
profile,
onSubmit,
isLoading
}: UserProfileDialogProps) {
const [activeTab, setActiveTab] = useState<'identity' | 'preferences'>('identity');
// Form fields for identity
const [identityData, setIdentityData] = useState<Partial<UserIdentity>>({
handle: '',
display_name: '',
first_name: '',
last_name: '',
email: '',
avatar_url: '',
bio: '',
});
// Form fields for preferences
const [preferencesData, setPreferencesData] = useState<Partial<UserPreferences>>({
theme: 'light',
language: 'en',
notifications_enabled: true,
privacy_level: 'standard',
auto_sync: false,
});
const [showDateOfBirth, setShowDateOfBirth] = useState(false);
const [hubDid, setHubDid] = useState<string>('');
// Initialize form with profile data when in edit mode
React.useEffect(() => {
if (mode === 'edit' && profile) {
setIdentityData({
handle: profile.identity.handle || '',
display_name: profile.identity.display_name || '',
first_name: profile.identity.first_name || '',
last_name: profile.identity.last_name || '',
email: profile.identity.email || '',
avatar_url: profile.identity.avatar_url || '',
bio: profile.identity.bio || '',
});
setPreferencesData({
theme: profile.preferences.theme || 'light',
language: profile.preferences.language || 'en',
notifications_enabled: profile.preferences.notifications_enabled ?? true,
privacy_level: profile.preferences.privacy_level || 'standard',
auto_sync: profile.preferences.auto_sync ?? false,
});
setShowDateOfBirth(profile.show_date_of_birth || false);
setHubDid(profile.hub_did || '');
} else {
// Reset form for create mode
setIdentityData({
handle: '',
display_name: '',
first_name: '',
last_name: '',
email: '',
avatar_url: '',
bio: '',
});
setPreferencesData({
theme: 'light',
language: 'en',
notifications_enabled: true,
privacy_level: 'standard',
auto_sync: false,
});
setShowDateOfBirth(false);
setHubDid('');
}
}, [mode, profile, open]);
const handleIdentityChange = (field: keyof UserIdentity) => (e: React.ChangeEvent<HTMLInputElement>) => {
setIdentityData(prev => ({
...prev,
[field]: e.target.value
}));
};
const handlePreferenceChange = (field: keyof UserPreferences) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value;
setPreferencesData(prev => ({
...prev,
[field]: value
}));
};
const handleSubmit = async () => {
if (!identityData.display_name) {
alert('Display name is required');
return;
}
await onSubmit(
identityData as UserIdentity,
preferencesData as UserPreferences,
showDateOfBirth,
hubDid || undefined
);
};
const handleClose = () => {
onOpenChange(false);
};
const isFormValid = identityData.display_name && identityData.display_name.trim() !== '';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'create' ? 'Create New User Profile' : `Edit Profile: ${profile?.identity.display_name || 'Unnamed Profile'}`}
</DialogTitle>
<DialogDescription>
{mode === 'create'
? 'Create a new user profile with identity information and preferences.'
: 'Update the user profile information and preferences.'
}
</DialogDescription>
</DialogHeader>
{/* Tab Navigation */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('identity')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'identity'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Identity Information
</button>
<button
onClick={() => setActiveTab('preferences')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'preferences'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Preferences & Settings
</button>
</nav>
</div>
<div className="py-4">
{/* Identity Tab */}
{activeTab === 'identity' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Basic Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="display_name" className="text-sm font-medium">
Display Name *
</Label>
<Input
id="display_name"
placeholder="Your display name"
value={identityData.display_name}
onChange={handleIdentityChange('display_name')}
required
/>
<p className="text-xs text-gray-500">
This is how others will see you
</p>
</div>
<div className="space-y-2">
<Label htmlFor="handle" className="text-sm font-medium">
Handle
</Label>
<Input
id="handle"
placeholder="Your unique handle"
value={identityData.handle}
onChange={handleIdentityChange('handle')}
/>
<p className="text-xs text-gray-500">
A unique identifier for your profile
</p>
</div>
</div>
{/* Hub DID Field - Full Width on its own line */}
<div className="space-y-2 mt-6">
<Label htmlFor="hub_did" className="text-sm font-medium">
Hub DID
</Label>
<Input
id="hub_did"
placeholder="did:example:123456789"
value={hubDid}
onChange={(e) => setHubDid(e.target.value)}
className="w-full"
/>
<p className="text-xs text-gray-500">
The DID of the hub this profile is affiliated with (leave empty for default profile)
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div className="space-y-2">
<Label htmlFor="first_name" className="text-sm font-medium">
First Name
</Label>
<Input
id="first_name"
placeholder="First name"
value={identityData.first_name}
onChange={handleIdentityChange('first_name')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name" className="text-sm font-medium">
Last Name
</Label>
<Input
id="last_name"
placeholder="Last name"
value={identityData.last_name}
onChange={handleIdentityChange('last_name')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
</Label>
<Input
id="email"
type="email"
placeholder="email@example.com"
value={identityData.email}
onChange={handleIdentityChange('email')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="avatar_url" className="text-sm font-medium">
Avatar URL
</Label>
<Input
id="avatar_url"
placeholder="https://example.com/avatar.jpg"
value={identityData.avatar_url}
onChange={handleIdentityChange('avatar_url')}
/>
<p className="text-xs text-gray-500">
URL to your profile picture
</p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Additional Information</h3>
<div className="space-y-2">
<Label htmlFor="bio" className="text-sm font-medium">
Bio
</Label>
<Input
id="bio"
placeholder="Tell us about yourself..."
value={identityData.bio}
onChange={handleIdentityChange('bio')}
/>
<p className="text-xs text-gray-500">
A brief description about yourself
</p>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Privacy Settings</h3>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="show_date_of_birth"
checked={showDateOfBirth}
onChange={(e) => setShowDateOfBirth(e.target.checked)}
className="h-4 w-4 text-blue-600"
/>
<Label htmlFor="show_date_of_birth" className="text-sm">
Show Date of Birth in Profile
</Label>
</div>
<p className="text-xs text-gray-500 mt-1">
When enabled, your date of birth will be visible to others
</p>
</div>
</div>
)}
{/* Preferences Tab */}
{activeTab === 'preferences' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Appearance</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="theme" className="text-sm font-medium">
Theme
</Label>
<select
id="theme"
value={preferencesData.theme}
onChange={handlePreferenceChange('theme')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="language" className="text-sm font-medium">
Language
</Label>
<select
id="language"
value={preferencesData.language}
onChange={handlePreferenceChange('language')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="ja">Japanese</option>
</select>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Notifications</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifications_enabled" className="text-sm font-medium">
Enable Notifications
</Label>
<p className="text-xs text-gray-500">
Receive notifications for important updates
</p>
</div>
<input
type="checkbox"
id="notifications_enabled"
checked={preferencesData.notifications_enabled}
onChange={(e) => handlePreferenceChange('notifications_enabled')(e)}
className="h-4 w-4 text-blue-600"
/>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Privacy & Security</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="privacy_level" className="text-sm font-medium">
Privacy Level
</Label>
<select
id="privacy_level"
value={preferencesData.privacy_level}
onChange={handlePreferenceChange('privacy_level')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="public">Public</option>
<option value="standard">Standard</option>
<option value="private">Private</option>
</select>
<p className="text-xs text-gray-500">
Controls who can see your profile information
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="auto_sync" className="text-sm font-medium">
Auto Sync
</Label>
<p className="text-xs text-gray-500">
Automatically sync your profile across devices
</p>
</div>
<input
type="checkbox"
id="auto_sync"
checked={preferencesData.auto_sync}
onChange={(e) => handlePreferenceChange('auto_sync')(e)}
className="h-4 w-4 text-blue-600"
/>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<div className="flex justify-between w-full">
<div className="flex space-x-2">
{activeTab === 'preferences' && (
<Button variant="outline" onClick={() => setActiveTab('identity')}>
Back to Identity
</Button>
)}
{activeTab === 'identity' && (
<Button variant="outline" onClick={() => setActiveTab('preferences')}>
Continue to Preferences
</Button>
)}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isLoading || !isFormValid}
>
{isLoading
? (mode === 'create' ? 'Creating...' : 'Updating...')
: (mode === 'create' ? 'Create Profile' : 'Update Profile')
}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}