4da08d91bc
Split-pane role editor with tri-state channel overrides (roles/members). CSS-var themes (Gruvbox default + 9 IDE palettes) in top bar and settings.
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { useAuthStore } from '../stores/auth.ts';
|
|
import { usePushStore } from '../stores/push.ts';
|
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
|
|
|
export function UserSettings() {
|
|
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
|
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
|
|
const navigate = useNavigate();
|
|
|
|
const [displayName, setDisplayName] = useState('');
|
|
const [bio, setBio] = useState('');
|
|
const [statusText, setStatusText] = useState('');
|
|
const [accentColor, setAccentColor] = useState('#fe8019');
|
|
const [saved, setSaved] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Password change state
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setDisplayName(user.display_name || '');
|
|
setBio(user.bio || '');
|
|
setStatusText(user.status_text || '');
|
|
setAccentColor(user.accent_color || '#fe8019');
|
|
}
|
|
checkSubscription();
|
|
}, [user, checkSubscription]);
|
|
|
|
if (!user) {
|
|
navigate('/login');
|
|
return null;
|
|
}
|
|
|
|
const handleSave = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
clearError();
|
|
setSaved(false);
|
|
|
|
try {
|
|
await updateProfile({
|
|
display_name: displayName,
|
|
bio,
|
|
status_text: statusText,
|
|
accent_color: accentColor,
|
|
});
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch {
|
|
// error is set in store
|
|
}
|
|
};
|
|
|
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploading(true);
|
|
setUploadError(null);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const response = await fetch('/api/v1/upload', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => null);
|
|
throw new Error(data?.message || data?.error || `Upload failed: ${response.status}`);
|
|
}
|
|
|
|
const uploadData = await response.json();
|
|
await updateProfile({ avatar_url: uploadData.url });
|
|
} catch (err) {
|
|
setUploadError(err instanceof Error ? err.message : 'Upload failed');
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const hexValid = /^#[0-9a-fA-F]{6}$/.test(accentColor);
|
|
|
|
const handleChangePassword = async () => {
|
|
setPasswordError(null);
|
|
setPasswordSuccess(false);
|
|
if (newPassword !== confirmNewPassword) {
|
|
setPasswordError("new passwords do not match");
|
|
return;
|
|
}
|
|
if (newPassword.length < 8) {
|
|
setPasswordError("password must be at least 8 characters");
|
|
return;
|
|
}
|
|
setPasswordLoading(true);
|
|
try {
|
|
await changePassword(currentPassword, newPassword);
|
|
setPasswordSuccess(true);
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmNewPassword('');
|
|
setTimeout(() => setPasswordSuccess(false), 3000);
|
|
} catch {
|
|
// error is set in store
|
|
} finally {
|
|
setPasswordLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
|
<div className="max-w-2xl mx-auto">
|
|
{/* Box-drawn frame */}
|
|
<div className="border border-gb-bg-t p-6">
|
|
{/* Header */}
|
|
<pre className="text-gb-orange font-mono text-center mb-6">
|
|
{'┌──────────────────────────────────┐\n'}
|
|
{'│ === PROFILE SETTINGS === │\n'}
|
|
{'└──────────────────────────────────┘'}
|
|
</pre>
|
|
|
|
<form onSubmit={handleSave} className="space-y-5">
|
|
{/* Avatar Section */}
|
|
<div className="border border-gb-bg-t p-4">
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
|
AVATAR:
|
|
</label>
|
|
<div className="flex items-center gap-4">
|
|
<div
|
|
className="w-16 h-16 rounded border-2 flex items-center justify-center overflow-hidden shrink-0"
|
|
style={{ borderColor: hexValid ? accentColor : '#504945' }}
|
|
>
|
|
{user.avatar ? (
|
|
<img
|
|
src={user.avatar}
|
|
alt="avatar"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<span className="text-gb-fg-f text-2xl font-mono">
|
|
{user.username.charAt(0).toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-gb-fg-s text-sm font-mono truncate max-w-xs">
|
|
{user.avatar || 'no avatar set'}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="terminal-button text-xs"
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? '[UPLOADING...]' : '[UPLOAD NEW]'}
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleAvatarUpload}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
{uploadError && (
|
|
<p className="text-gb-red text-xs font-mono">ERR: {uploadError}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
|
DISPLAY_NAME:
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value.slice(0, 32))}
|
|
maxLength={32}
|
|
className="terminal-input w-full"
|
|
placeholder="your display name"
|
|
/>
|
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
|
{displayName.length}/32
|
|
</span>
|
|
</div>
|
|
|
|
{/* Bio */}
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
|
BIO:
|
|
</label>
|
|
<textarea
|
|
value={bio}
|
|
onChange={(e) => setBio(e.target.value.slice(0, 250))}
|
|
maxLength={250}
|
|
rows={4}
|
|
className="terminal-input w-full resize-none"
|
|
placeholder="tell the world about yourself..."
|
|
/>
|
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
|
{bio.length}/250
|
|
</span>
|
|
</div>
|
|
|
|
{/* Status Text */}
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
|
STATUS_TEXT:
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={statusText}
|
|
onChange={(e) => setStatusText(e.target.value.slice(0, 128))}
|
|
maxLength={128}
|
|
className="terminal-input w-full"
|
|
placeholder="what are you up to?"
|
|
/>
|
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
|
{statusText.length}/128
|
|
</span>
|
|
</div>
|
|
|
|
{/* Accent Color */}
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
|
ACCENT_COLOR:
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={accentColor}
|
|
onChange={(e) => {
|
|
let val = e.target.value;
|
|
if (!val.startsWith('#')) val = '#' + val;
|
|
setAccentColor(val.slice(0, 7));
|
|
}}
|
|
maxLength={7}
|
|
className="terminal-input w-32"
|
|
placeholder="#fe8019"
|
|
/>
|
|
<div
|
|
className="w-8 h-8 border border-gb-bg-t rounded-sm shrink-0"
|
|
style={{ backgroundColor: hexValid ? accentColor : '#504945' }}
|
|
title={hexValid ? accentColor : 'invalid hex'}
|
|
/>
|
|
{!hexValid && accentColor.length > 0 && (
|
|
<span className="text-gb-red text-xs font-mono">
|
|
invalid hex
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Change Password */}
|
|
<div className="border border-gb-bg-t p-4">
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
|
CHANGE PASSWORD:
|
|
</label>
|
|
<div className="space-y-3">
|
|
<input
|
|
type="password"
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
className="terminal-input w-full"
|
|
placeholder="current password"
|
|
autoComplete="current-password"
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
className="terminal-input w-full"
|
|
placeholder="new password (min 8 chars)"
|
|
autoComplete="new-password"
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={confirmNewPassword}
|
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
className="terminal-input w-full"
|
|
placeholder="confirm new password"
|
|
autoComplete="new-password"
|
|
/>
|
|
{passwordError && (
|
|
<p className="text-gb-red text-xs font-mono">ERR: {passwordError}</p>
|
|
)}
|
|
{passwordSuccess && (
|
|
<p className="text-gb-green text-xs font-mono">[password changed!]</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleChangePassword}
|
|
disabled={passwordLoading || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword}
|
|
className="terminal-button text-xs"
|
|
>
|
|
{passwordLoading ? '[CHANGING...]' : '[CHANGE PASSWORD]'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Appearance */}
|
|
<div className="border border-gb-bg-t p-4">
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
|
THEME:
|
|
</label>
|
|
<ThemeToggle />
|
|
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
|
Default is Gruvbox. Choice is saved in this browser.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Push Notifications */}
|
|
{isSupported && (
|
|
<div className="border border-gb-bg-t p-4">
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
|
NOTIFICATIONS:
|
|
</label>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (Notification.permission !== 'granted') {
|
|
await Notification.requestPermission();
|
|
// Force a re-render to update the permission status
|
|
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
|
|
}
|
|
}}
|
|
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
|
>
|
|
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
|
|
</button>
|
|
<span className="text-gb-fg-f text-xs font-mono">
|
|
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={isSubscribed ? unsubscribe : subscribe}
|
|
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
|
>
|
|
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
|
|
</button>
|
|
<span className="text-gb-fg-f text-xs font-mono">
|
|
{isSubscribed ? '● subscribed' : '○ not subscribed'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
|
Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Passkey Registration */}
|
|
<div className="border border-gb-bg-t p-4">
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
|
PASSKEYS:
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
const resp = await fetch('/api/v1/auth/webauthn/register/begin', { method: 'POST', credentials: 'include' });
|
|
if (!resp.ok) throw new Error('Passkey registration not available');
|
|
const options = await resp.json();
|
|
|
|
const credential = await navigator.credentials.create({ publicKey: options });
|
|
if (!credential) return;
|
|
|
|
const finishResp = await fetch('/api/v1/auth/webauthn/register/finish', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(credential),
|
|
});
|
|
|
|
if (finishResp.ok) {
|
|
alert('Passkey registered successfully!');
|
|
}
|
|
} catch (err) {
|
|
console.error('Passkey registration failed:', err);
|
|
alert('Passkey registration not yet available on this server.');
|
|
}
|
|
}}
|
|
className="terminal-button text-xs border-gb-aqua text-gb-aqua"
|
|
>
|
|
[REGISTER PASSKEY]
|
|
</button>
|
|
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
|
Use biometrics or security key for passwordless login.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Errors & Success */}
|
|
{error && (
|
|
<p className="text-gb-red text-sm font-mono">ERR: {error}</p>
|
|
)}
|
|
{saved && (
|
|
<p className="text-gb-green text-sm font-mono animate-pulse">
|
|
[saved!]
|
|
</p>
|
|
)}
|
|
|
|
{/* Save Button */}
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
className="terminal-button"
|
|
disabled={isLoading || !hexValid}
|
|
>
|
|
{isLoading ? '[SAVING...]' : '[SAVE]'}
|
|
</button>
|
|
<Link
|
|
to="/"
|
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
|
>
|
|
[CANCEL]
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Footer info */}
|
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
|
<p className="text-gb-fg-f text-xs font-mono">
|
|
USER: {user.username} | ID: {user.id.slice(0, 8)}... | JOINED: {new Date(user.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|