@1889ca/ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1889ca/ui",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "peerDependencies": {
24
24
  "react": ">=18.0.0",
25
- "react-dom": ">=18.0.0"
25
+ "react-dom": ">=18.0.0",
26
+ "@simplewebauthn/browser": ">=13.0.0"
26
27
  }
27
28
  }
@@ -0,0 +1,57 @@
1
+ import { createContext, useContext, useState, useEffect } from 'react';
2
+ import {
3
+ authenticatePasskey,
4
+ registerPasskey,
5
+ devLogin as devLoginApi,
6
+ fetchCurrentUser,
7
+ logout as logoutApi,
8
+ } from './api.js';
9
+
10
+ const AuthContext = createContext(null);
11
+
12
+ export function AuthProvider({ children }) {
13
+ const [user, setUser] = useState(null);
14
+ const [loading, setLoading] = useState(true);
15
+
16
+ useEffect(() => {
17
+ fetchCurrentUser()
18
+ .then(setUser)
19
+ .catch(() => setUser(null))
20
+ .finally(() => setLoading(false));
21
+ }, []);
22
+
23
+ async function login() {
24
+ const result = await authenticatePasskey();
25
+ setUser(result.user);
26
+ return result;
27
+ }
28
+
29
+ async function register(name) {
30
+ const result = await registerPasskey(name);
31
+ setUser(result.user);
32
+ return result;
33
+ }
34
+
35
+ async function devLogin(name = 'Test User') {
36
+ const result = await devLoginApi(name);
37
+ setUser(result.user);
38
+ return result;
39
+ }
40
+
41
+ async function logout() {
42
+ await logoutApi();
43
+ setUser(null);
44
+ }
45
+
46
+ return (
47
+ <AuthContext.Provider value={{ user, loading, login, register, devLogin, logout, setUser }}>
48
+ {children}
49
+ </AuthContext.Provider>
50
+ );
51
+ }
52
+
53
+ export function useAuth() {
54
+ const ctx = useContext(AuthContext);
55
+ if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
56
+ return ctx;
57
+ }
@@ -0,0 +1,51 @@
1
+ import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
2
+
3
+ async function request(method, path, body) {
4
+ const options = {
5
+ method,
6
+ credentials: 'include',
7
+ headers: {},
8
+ };
9
+
10
+ if (body !== undefined) {
11
+ options.headers['Content-Type'] = 'application/json';
12
+ options.body = JSON.stringify(body);
13
+ }
14
+
15
+ const res = await fetch(path, options);
16
+
17
+ if (res.status === 204) return null;
18
+
19
+ const data = await res.json();
20
+ if (!res.ok) {
21
+ const err = new Error(data?.error || `HTTP ${res.status}`);
22
+ err.status = res.status;
23
+ throw err;
24
+ }
25
+
26
+ return data;
27
+ }
28
+
29
+ export async function registerPasskey(username) {
30
+ const options = await request('POST', '/api/auth/register/challenge', { name: username });
31
+ const credential = await startRegistration({ optionsJSON: options });
32
+ return request('POST', '/api/auth/register/verify', credential);
33
+ }
34
+
35
+ export async function authenticatePasskey() {
36
+ const options = await request('POST', '/api/auth/authenticate/challenge');
37
+ const credential = await startAuthentication({ optionsJSON: options });
38
+ return request('POST', '/api/auth/authenticate/verify', credential);
39
+ }
40
+
41
+ export async function devLogin(name = 'Test User') {
42
+ return request('POST', '/api/auth/dev-login', { name });
43
+ }
44
+
45
+ export async function fetchCurrentUser() {
46
+ return request('GET', '/api/auth/me');
47
+ }
48
+
49
+ export async function logout() {
50
+ return request('POST', '/api/auth/logout');
51
+ }
@@ -0,0 +1,98 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-login-card {
4
+ position: relative;
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 1rem;
8
+ width: 100%;
9
+ max-width: 400px;
10
+ padding: 2rem;
11
+ background: rgba(18, 18, 24, 0.7);
12
+ backdrop-filter: blur(var(--ui-glass-blur-heavy));
13
+ border: 1px solid var(--ui-border);
14
+ border-top-color: var(--ui-border-strong);
15
+ border-radius: var(--ui-radius-lg);
16
+ box-shadow: var(--ui-shadow-float);
17
+ }
18
+
19
+ /* Dev mode corner badge */
20
+ .ui-login-dev-badge {
21
+ position: absolute;
22
+ top: -1px;
23
+ right: -1px;
24
+ background: linear-gradient(135deg, #f59e0b, #f97316);
25
+ color: #000;
26
+ font-size: 0.65rem;
27
+ font-weight: 700;
28
+ letter-spacing: 0.08em;
29
+ padding: 0.2rem 0.65rem;
30
+ border-radius: 0 var(--ui-radius-lg) 0 var(--ui-radius);
31
+ box-shadow: 0 0 12px rgba(245, 158, 11, 0.3);
32
+ text-transform: uppercase;
33
+ }
34
+
35
+ /* Gradient title */
36
+ .ui-login-title {
37
+ font-size: 2rem;
38
+ font-weight: 700;
39
+ letter-spacing: -0.03em;
40
+ line-height: 1.2;
41
+ background: linear-gradient(135deg, #fff 0%, var(--ui-accent) 100%);
42
+ -webkit-background-clip: text;
43
+ -webkit-text-fill-color: transparent;
44
+ background-clip: text;
45
+ }
46
+
47
+ .ui-login-subtitle {
48
+ color: var(--ui-text-muted);
49
+ font-size: 0.88rem;
50
+ margin-top: -0.5rem;
51
+ }
52
+
53
+ /* Tab switcher */
54
+ .ui-login-tabs {
55
+ display: flex;
56
+ border: 1px solid var(--ui-border);
57
+ border-radius: var(--ui-radius);
58
+ overflow: hidden;
59
+ }
60
+
61
+ .ui-login-tab {
62
+ flex: 1;
63
+ background: transparent;
64
+ border: none;
65
+ color: var(--ui-text-muted);
66
+ padding: 0.55rem 0.5rem;
67
+ font-size: 0.85rem;
68
+ font-weight: 500;
69
+ cursor: pointer;
70
+ transition: all 0.2s var(--ui-ease);
71
+ font-family: inherit;
72
+ }
73
+
74
+ .ui-login-tab:hover {
75
+ color: var(--ui-text);
76
+ }
77
+
78
+ .ui-login-tab--active {
79
+ background: var(--ui-surface-raised);
80
+ color: var(--ui-text);
81
+ }
82
+
83
+ /* Body / form area */
84
+ .ui-login-body {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 0.75rem;
88
+ }
89
+
90
+ /* Error display */
91
+ .ui-login-error {
92
+ color: var(--ui-danger);
93
+ font-size: 0.82rem;
94
+ padding: 0.5rem 0.75rem;
95
+ background: rgba(240, 96, 104, 0.08);
96
+ border: 1px solid rgba(240, 96, 104, 0.2);
97
+ border-radius: var(--ui-radius);
98
+ }
@@ -0,0 +1,46 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useState } from 'react';
3
+
4
+ /**
5
+ * Styled login card with optional dev-mode badge, gradient title,
6
+ * tab switcher, and form layout. Presentational only — consumers
7
+ * supply callbacks for auth logic.
8
+ *
9
+ * @param {string} title - App name / heading
10
+ * @param {string} subtitle - Short tagline
11
+ * @param {boolean} devMode - Show "DEV MODE" corner badge
12
+ * @param {Array} tabs - [{label, id}] for mode switcher (omit for single-mode)
13
+ * @param {string} activeTab - Currently active tab id
14
+ * @param {function} onTabChange - Called with tab id
15
+ * @param {string} error - Error message to display
16
+ * @param {ReactNode} children - Form content
17
+ */
18
+ export default function LoginCard({ title, subtitle, devMode, tabs, activeTab, onTabChange, error, children }) {
19
+ return (
20
+ <div className="ui-login-card">
21
+ {devMode && <div className="ui-login-dev-badge">DEV MODE</div>}
22
+ {title && <h1 className="ui-login-title">{title}</h1>}
23
+ {subtitle && <p className="ui-login-subtitle">{subtitle}</p>}
24
+ {tabs && tabs.length > 0 && (
25
+ <div className="ui-login-tabs">
26
+ {tabs.map((tab) => (
27
+ <button
28
+ key={tab.id}
29
+ type="button"
30
+ className={`ui-login-tab${activeTab === tab.id ? ' ui-login-tab--active' : ''}`}
31
+ onClick={() => onTabChange?.(tab.id)}
32
+ >
33
+ {tab.label}
34
+ </button>
35
+ ))}
36
+ </div>
37
+ )}
38
+ <div className="ui-login-body">
39
+ {error && (
40
+ <p className="ui-login-error">{error}</p>
41
+ )}
42
+ {children}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,61 @@
1
+ .ui-login-page {
2
+ min-height: 100vh;
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ background: var(--ui-bg);
7
+ padding: 1rem;
8
+ }
9
+
10
+ .ui-login-section {
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: 0.75rem;
14
+ }
15
+
16
+ .ui-login-hint {
17
+ color: var(--ui-text-muted);
18
+ font-size: 0.88rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .ui-login-label {
23
+ font-size: 0.88rem;
24
+ color: var(--ui-text-muted);
25
+ font-weight: 500;
26
+ }
27
+
28
+ .ui-login-btn-full {
29
+ width: 100%;
30
+ }
31
+
32
+ .ui-login-footer {
33
+ color: var(--ui-text-subtle);
34
+ font-size: 0.78rem;
35
+ text-align: center;
36
+ line-height: 1.5;
37
+ }
38
+
39
+ .ui-login-dev-section {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: 0.75rem;
43
+ }
44
+
45
+ .ui-login-dev-divider {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 0.75rem;
49
+ color: var(--ui-text-subtle);
50
+ font-size: 0.75rem;
51
+ text-transform: uppercase;
52
+ letter-spacing: 0.05em;
53
+ }
54
+
55
+ .ui-login-dev-divider::before,
56
+ .ui-login-dev-divider::after {
57
+ content: '';
58
+ flex: 1;
59
+ height: 1px;
60
+ background: var(--ui-border);
61
+ }
@@ -0,0 +1,129 @@
1
+ import { useState } from 'react';
2
+ import LoginCard from './LoginCard.jsx';
3
+
4
+ const TABS = [
5
+ { label: 'Sign In', id: 'login' },
6
+ { label: 'Register', id: 'register' },
7
+ ];
8
+
9
+ /**
10
+ * Full-page passkey login with Sign In / Register tabs and optional dev mode.
11
+ *
12
+ * @param {string} title - App name
13
+ * @param {string} subtitle - App tagline
14
+ * @param {function} login - async () => {user} — passkey auth
15
+ * @param {function} register - async (name) => {user} — passkey registration
16
+ * @param {function} devLogin - async (name) => {user} — dev bypass
17
+ * @param {function} onSuccess - called with user after any successful auth
18
+ * @param {boolean} devMode - show dev login section (defaults to import.meta.env.DEV)
19
+ */
20
+ export default function PasskeyLoginPage({ title, subtitle, login, register, devLogin, onSuccess, devMode }) {
21
+ const [mode, setMode] = useState('login');
22
+ const [name, setName] = useState('');
23
+ const [error, setError] = useState('');
24
+ const [loading, setLoading] = useState(false);
25
+
26
+ const showDev = devMode !== undefined ? devMode : (typeof import !== 'undefined' && import.meta?.env?.DEV);
27
+
28
+ async function handleLogin() {
29
+ setError('');
30
+ setLoading(true);
31
+ try {
32
+ const result = await login();
33
+ if (result?.user) onSuccess?.(result.user);
34
+ } catch (err) {
35
+ setError(err.message || 'Authentication failed');
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ }
40
+
41
+ async function handleRegister(e) {
42
+ e.preventDefault();
43
+ if (!name.trim()) return setError('Please enter your name');
44
+ setError('');
45
+ setLoading(true);
46
+ try {
47
+ const result = await register(name.trim());
48
+ if (result?.user) onSuccess?.(result.user);
49
+ } catch (err) {
50
+ setError(err.message || 'Registration failed');
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ }
55
+
56
+ async function handleDevLogin() {
57
+ setError('');
58
+ setLoading(true);
59
+ try {
60
+ const result = await devLogin('Test User');
61
+ if (result?.user) onSuccess?.(result.user);
62
+ } catch (err) {
63
+ setError(err.message || 'Dev login failed');
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div className="ui-login-page">
71
+ <LoginCard
72
+ title={title}
73
+ subtitle={subtitle}
74
+ devMode={showDev}
75
+ tabs={TABS}
76
+ activeTab={mode}
77
+ onTabChange={(id) => { setMode(id); setError(''); }}
78
+ error={error}
79
+ >
80
+ {mode === 'login' ? (
81
+ <div className="ui-login-section">
82
+ <p className="ui-login-hint">Use your passkey to sign in securely.</p>
83
+ <button
84
+ className="ui-btn ui-btn-primary ui-login-btn-full"
85
+ onClick={handleLogin}
86
+ disabled={loading}
87
+ >
88
+ {loading ? 'Authenticating...' : 'Sign in with Passkey'}
89
+ </button>
90
+ </div>
91
+ ) : (
92
+ <form className="ui-login-section" onSubmit={handleRegister}>
93
+ <label htmlFor="ui-login-name" className="ui-login-label">Your name</label>
94
+ <input
95
+ id="ui-login-name"
96
+ className="ui-input"
97
+ type="text"
98
+ value={name}
99
+ onChange={(e) => setName(e.target.value)}
100
+ placeholder="e.g. Jane Smith"
101
+ autoComplete="name"
102
+ disabled={loading}
103
+ />
104
+ <button className="ui-btn ui-btn-primary ui-login-btn-full" type="submit" disabled={loading}>
105
+ {loading ? 'Registering...' : 'Register with Passkey'}
106
+ </button>
107
+ </form>
108
+ )}
109
+
110
+ {showDev && devLogin && (
111
+ <div className="ui-login-dev-section">
112
+ <div className="ui-login-dev-divider"><span>Dev Mode</span></div>
113
+ <button
114
+ className="ui-btn ui-btn-ghost ui-login-btn-full"
115
+ onClick={handleDevLogin}
116
+ disabled={loading}
117
+ >
118
+ {loading ? 'Logging in...' : 'Dev Login (skip passkey)'}
119
+ </button>
120
+ </div>
121
+ )}
122
+
123
+ <p className="ui-login-footer">
124
+ Passkeys use your device's biometrics — no passwords needed.
125
+ </p>
126
+ </LoginCard>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,105 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-user-menu {
4
+ position: relative;
5
+ z-index: 100;
6
+ }
7
+
8
+ /* Trigger — circular avatar button */
9
+ .ui-user-menu-trigger {
10
+ width: 34px;
11
+ height: 34px;
12
+ border-radius: 50%;
13
+ border: 1px solid var(--ui-border);
14
+ background: var(--ui-accent-muted);
15
+ cursor: pointer;
16
+ padding: 0;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ transition: border-color 0.15s, box-shadow 0.15s;
21
+ overflow: hidden;
22
+ }
23
+
24
+ .ui-user-menu-trigger:hover {
25
+ border-color: var(--ui-accent);
26
+ box-shadow: 0 0 0 2px var(--ui-accent-glow);
27
+ }
28
+
29
+ .ui-user-menu-avatar {
30
+ width: 100%;
31
+ height: 100%;
32
+ object-fit: cover;
33
+ border-radius: 50%;
34
+ }
35
+
36
+ .ui-user-menu-initials {
37
+ font-size: 0.82rem;
38
+ font-weight: 600;
39
+ color: var(--ui-accent);
40
+ line-height: 1;
41
+ user-select: none;
42
+ }
43
+
44
+ /* Dropdown — glassmorphic panel */
45
+ .ui-user-menu-dropdown {
46
+ position: absolute;
47
+ top: calc(100% + 6px);
48
+ right: 0;
49
+ min-width: 180px;
50
+ background: rgba(18, 18, 24, 0.82);
51
+ backdrop-filter: blur(var(--ui-glass-blur-heavy));
52
+ border: 1px solid var(--ui-border);
53
+ border-top-color: var(--ui-border-strong);
54
+ border-radius: var(--ui-radius);
55
+ padding: 4px;
56
+ box-shadow: var(--ui-shadow-float);
57
+ animation: ui-user-menu-in 0.12s var(--ui-ease);
58
+ z-index: 1000;
59
+ }
60
+
61
+ @keyframes ui-user-menu-in {
62
+ from { opacity: 0; transform: scale(0.96) translateY(-4px); }
63
+ to { opacity: 1; transform: scale(1) translateY(0); }
64
+ }
65
+
66
+ /* Name label */
67
+ .ui-user-menu-name {
68
+ padding: 7px 12px;
69
+ font-size: 0.78rem;
70
+ font-weight: 500;
71
+ color: var(--ui-text-muted);
72
+ user-select: none;
73
+ }
74
+
75
+ /* Menu items */
76
+ .ui-user-menu-item {
77
+ display: block;
78
+ width: 100%;
79
+ padding: 7px 12px;
80
+ background: none;
81
+ border: none;
82
+ color: var(--ui-text);
83
+ font-size: 0.82rem;
84
+ text-align: left;
85
+ border-radius: var(--ui-radius-sm);
86
+ cursor: pointer;
87
+ transition: background 0.12s, color 0.12s;
88
+ }
89
+
90
+ .ui-user-menu-item:hover {
91
+ background: var(--ui-accent-muted);
92
+ color: #fff;
93
+ }
94
+
95
+ .ui-user-menu-item--danger:hover {
96
+ background: var(--ui-danger-glow);
97
+ color: var(--ui-danger);
98
+ }
99
+
100
+ /* Separator */
101
+ .ui-user-menu-sep {
102
+ height: 1px;
103
+ background: var(--ui-border);
104
+ margin: 4px 8px;
105
+ }
@@ -0,0 +1,73 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useState, useEffect, useRef } from 'react';
3
+
4
+ /**
5
+ * User avatar menu with glassmorphic dropdown.
6
+ * Trigger shows avatar image or first-letter initials.
7
+ * Dropdown shows user name, app-specific items, and a pinned logout action.
8
+ *
9
+ * @param {string} name - User display name
10
+ * @param {string} [avatar] - Optional avatar URL (falls back to initials)
11
+ * @param {Array} items - [{ label, action }] app-specific menu items
12
+ * @param {function} onLogout - Always rendered last with separator + danger style
13
+ */
14
+ export default function UserMenu({ name, avatar, items = [], onLogout }) {
15
+ const [open, setOpen] = useState(false);
16
+ const ref = useRef(null);
17
+
18
+ useEffect(() => {
19
+ if (!open) return;
20
+ function onMouseDown(e) {
21
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
22
+ }
23
+ function onKey(e) {
24
+ if (e.key === 'Escape') setOpen(false);
25
+ }
26
+ document.addEventListener('mousedown', onMouseDown);
27
+ document.addEventListener('keydown', onKey);
28
+ return () => {
29
+ document.removeEventListener('mousedown', onMouseDown);
30
+ document.removeEventListener('keydown', onKey);
31
+ };
32
+ }, [open]);
33
+
34
+ const initials = name ? name.charAt(0).toUpperCase() : '?';
35
+
36
+ return (
37
+ <div ref={ref} className="ui-user-menu">
38
+ <button
39
+ className="ui-user-menu-trigger"
40
+ onClick={() => setOpen(prev => !prev)}
41
+ aria-label={`User menu for ${name}`}
42
+ >
43
+ {avatar
44
+ ? <img className="ui-user-menu-avatar" src={avatar} alt={name} />
45
+ : <span className="ui-user-menu-initials">{initials}</span>
46
+ }
47
+ </button>
48
+
49
+ {open && (
50
+ <div className="ui-user-menu-dropdown">
51
+ <div className="ui-user-menu-name">{name}</div>
52
+ <div className="ui-user-menu-sep" />
53
+ {items.map((item, i) => (
54
+ <button
55
+ key={i}
56
+ className="ui-user-menu-item"
57
+ onClick={() => { item.action(); setOpen(false); }}
58
+ >
59
+ {item.label}
60
+ </button>
61
+ ))}
62
+ {items.length > 0 && <div className="ui-user-menu-sep" />}
63
+ <button
64
+ className="ui-user-menu-item ui-user-menu-item--danger"
65
+ onClick={() => { onLogout(); setOpen(false); }}
66
+ >
67
+ Log out
68
+ </button>
69
+ </div>
70
+ )}
71
+ </div>
72
+ );
73
+ }
package/src/index.js CHANGED
@@ -16,6 +16,9 @@ import './components/ItemGrid.css';
16
16
  import './components/ItemCard.css';
17
17
  import './components/FolderCard.css';
18
18
  import './components/SlidePanel.css';
19
+ import './components/LoginCard.css';
20
+ import './components/PasskeyLoginPage.css';
21
+ import './components/UserMenu.css';
19
22
 
20
23
  /* Components */
21
24
  export { default as ContextMenu } from './components/ContextMenu.jsx';
@@ -30,6 +33,13 @@ export { default as ItemGrid } from './components/ItemGrid.jsx';
30
33
  export { default as ItemCard } from './components/ItemCard.jsx';
31
34
  export { default as FolderCard } from './components/FolderCard.jsx';
32
35
  export { default as SlidePanel } from './components/SlidePanel.jsx';
36
+ export { default as LoginCard } from './components/LoginCard.jsx';
37
+ export { default as PasskeyLoginPage } from './components/PasskeyLoginPage.jsx';
38
+ export { default as UserMenu } from './components/UserMenu.jsx';
39
+
40
+ /* Auth */
41
+ export { AuthProvider, useAuth } from './auth/AuthProvider.jsx';
42
+ export { registerPasskey, authenticatePasskey, devLogin, fetchCurrentUser, logout } from './auth/api.js';
33
43
 
34
44
  /* Hooks */
35
45
  export { default as useMultiSelect } from './hooks/useMultiSelect.js';