create-githat-app 1.8.5 → 1.8.7
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/dist/cli.js +2 -2
- package/package.json +1 -1
- package/templates/agent/app/account/activity/page.tsx.hbs +176 -0
- package/templates/agent/app/account/files/page.tsx.hbs +241 -0
- package/templates/agent/app/account/security/page.tsx.hbs +46 -0
- package/templates/agent/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/base/README.md.hbs +111 -0
- package/templates/classroom/app/account/activity/page.tsx.hbs +176 -0
- package/templates/classroom/app/account/files/page.tsx.hbs +241 -0
- package/templates/classroom/app/account/security/page.tsx.hbs +46 -0
- package/templates/classroom/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/content/app/account/activity/page.tsx.hbs +176 -0
- package/templates/content/app/account/files/page.tsx.hbs +241 -0
- package/templates/content/app/account/security/page.tsx.hbs +46 -0
- package/templates/content/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/dashboard/app/account/activity/page.tsx.hbs +176 -0
- package/templates/dashboard/app/account/files/page.tsx.hbs +241 -0
- package/templates/dashboard/app/account/security/page.tsx.hbs +46 -0
- package/templates/dashboard/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/marketplace/app/account/activity/page.tsx.hbs +176 -0
- package/templates/marketplace/app/account/files/page.tsx.hbs +241 -0
- package/templates/marketplace/app/account/security/page.tsx.hbs +46 -0
- package/templates/marketplace/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/marketplace/app/admin/page.tsx.hbs +19 -19
- package/templates/marketplace/app/cart/page.tsx.hbs +23 -21
- package/templates/marketplace/app/layout.tsx.hbs +7 -7
- package/templates/marketplace/app/page.tsx.hbs +82 -31
- package/templates/marketplace/app/sell/page.tsx.hbs +17 -18
- package/templates/marketplace/src/data/products.ts.hbs +64 -0
- package/templates/marketplace/src/lib/categories.ts.hbs +17 -23
- package/templates/nextjs/app/account/activity/page.tsx.hbs +176 -0
- package/templates/nextjs/app/account/files/page.tsx.hbs +241 -0
- package/templates/nextjs/app/account/security/page.tsx.hbs +46 -0
- package/templates/nextjs/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/plain/app/account/activity/page.tsx.hbs +176 -0
- package/templates/plain/app/account/files/page.tsx.hbs +241 -0
- package/templates/plain/app/account/security/page.tsx.hbs +46 -0
- package/templates/plain/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/portfolio/app/account/activity/page.tsx.hbs +176 -0
- package/templates/portfolio/app/account/files/page.tsx.hbs +241 -0
- package/templates/portfolio/app/account/security/page.tsx.hbs +46 -0
- package/templates/portfolio/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/saas/app/account/activity/page.tsx.hbs +176 -0
- package/templates/saas/app/account/files/page.tsx.hbs +241 -0
- package/templates/saas/app/account/security/page.tsx.hbs +46 -0
- package/templates/saas/app/account/sessions/page.tsx.hbs +180 -0
|
@@ -100,6 +100,44 @@ const domains = await list(); // includes verificationStatus
|
|
|
100
100
|
Once verified, links in emails point to `https://{{domain}}/reset-password?token=…`
|
|
101
101
|
— users never see githat.io.
|
|
102
102
|
|
|
103
|
+
## Webhooks
|
|
104
|
+
|
|
105
|
+
Subscribe to GitHat auth events from your backend via HTTP callbacks. Use the
|
|
106
|
+
GitHat dashboard (`/webhooks`) or the `useWebhooks()` hook to register an endpoint:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { useWebhooks } from '@githat/nextjs';
|
|
110
|
+
|
|
111
|
+
const { create, list, listDeliveries } = useWebhooks();
|
|
112
|
+
const webhook = await create('https://yourapp.com/hooks/githat', ['user.signed_in', 'user.created']);
|
|
113
|
+
// Save webhook.secret — it's shown only once and used to verify HMAC signatures.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Each delivery sends `X-GitHat-Signature: sha256=<hmac>` so you can verify authenticity.
|
|
117
|
+
|
|
118
|
+
## Active sessions
|
|
119
|
+
|
|
120
|
+
Users can view and revoke active sessions at `/account/sessions`. Each session
|
|
121
|
+
shows the user agent, IP, country, and last active time. The current session is
|
|
122
|
+
marked "This device". The `useSessions()` hook backs this page:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { useSessions } from '@githat/nextjs';
|
|
126
|
+
const { list, revoke, revokeAll } = useSessions();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Sign-in activity
|
|
130
|
+
|
|
131
|
+
`/account/activity` shows an immutable audit log of every auth event
|
|
132
|
+
(sign-ins, MFA toggles, passkey changes, etc.) with IP, country, and device.
|
|
133
|
+
Paginated with `useAuditLog()`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { useAuditLog } from '@githat/nextjs';
|
|
137
|
+
const { list } = useAuditLog();
|
|
138
|
+
const { events, nextCursor } = await list({ limit: 50 });
|
|
139
|
+
```
|
|
140
|
+
|
|
103
141
|
## Two-factor authentication
|
|
104
142
|
|
|
105
143
|
Users can enable TOTP-based 2FA at `/account/security` (the page is
|
|
@@ -143,3 +181,76 @@ model.
|
|
|
143
181
|
- [Branded Auth Emails Guide](https://githat.io/docs/email-domains)
|
|
144
182
|
- [SDK Reference](https://www.npmjs.com/package/@githat/nextjs)
|
|
145
183
|
- [API Reference](https://githat.io/docs/api)
|
|
184
|
+
|
|
185
|
+
## Marketplace template
|
|
186
|
+
|
|
187
|
+
If you scaffolded with `--template marketplace`, your product catalog lives in one place:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
src/data/products.ts
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Adding products
|
|
194
|
+
|
|
195
|
+
Open `src/data/products.ts` and add entries to the `PRODUCTS` array:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
{
|
|
199
|
+
id: 'my-product', // unique, URL-safe string
|
|
200
|
+
name: 'My product',
|
|
201
|
+
description: 'A short description shown on the card.',
|
|
202
|
+
price: 12.99, // numeric, in your currency's base unit
|
|
203
|
+
category: 'food', // must match a slug in src/lib/categories.ts
|
|
204
|
+
inStock: true,
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The homepage, cart shell, and search page all import from this file via
|
|
209
|
+
`getAvailableProducts()` — no other changes needed.
|
|
210
|
+
|
|
211
|
+
### Adding categories
|
|
212
|
+
|
|
213
|
+
Open `src/lib/categories.ts` and add entries to the `CATEGORIES` array.
|
|
214
|
+
Keep `slug` values lowercase and hyphen-separated (used in URLs).
|
|
215
|
+
|
|
216
|
+
### Moving to a real database
|
|
217
|
+
|
|
218
|
+
When you're ready to persist products in a backend (Postgres, DynamoDB,
|
|
219
|
+
Sebastn), replace `getAvailableProducts()` in `app/page.tsx` with a
|
|
220
|
+
`fetch()` to your API. The `Product` interface in `src/data/products.ts`
|
|
221
|
+
stays the same so your UI components don't need to change.
|
|
222
|
+
|
|
223
|
+
## File storage
|
|
224
|
+
|
|
225
|
+
`/account/files` lets users upload files directly to per-app S3 buckets
|
|
226
|
+
via the GitHat storage API. Files never route through the GitHat server —
|
|
227
|
+
the SDK gets a presigned POST URL, uploads directly to S3, then calls
|
|
228
|
+
`/storage/finalize` to record the object.
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { useStorage, StorageDropzone } from '@githat/nextjs';
|
|
232
|
+
|
|
233
|
+
const { upload, list, getUrl, remove, update } = useStorage();
|
|
234
|
+
|
|
235
|
+
// Drag-drop upload UI with progress bar (no extra dependencies)
|
|
236
|
+
<StorageDropzone
|
|
237
|
+
accept="image/*,application/pdf"
|
|
238
|
+
onUpload={(obj) => console.log('uploaded:', obj.objectId)}
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
// Programmatic upload
|
|
242
|
+
const obj = await upload(file, { isPublic: false, metadata: { tag: 'avatar' } });
|
|
243
|
+
|
|
244
|
+
// List user's files (paginated)
|
|
245
|
+
const { items, nextCursor } = await list({ prefix: 'avatars/', limit: 20 });
|
|
246
|
+
|
|
247
|
+
// Get a 5-minute presigned download URL (or permanent URL for public objects)
|
|
248
|
+
const { downloadUrl } = await getUrl(obj.objectId);
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Key properties:
|
|
252
|
+
- **Per-app S3 buckets** — each app gets its own bucket (`githat-storage-<hash>`)
|
|
253
|
+
with CORS configured for the app's verified domain.
|
|
254
|
+
- **Static-export-friendly** — no server actions. All storage calls are
|
|
255
|
+
client-side fetches against the GitHat API.
|
|
256
|
+
- **Private by default** — set `isPublic: true` for public CDN URLs.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useAuditLog } from '@githat/nextjs';
|
|
5
|
+
import type { AuditEvent, AuditEventType } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
const EVENT_ICONS: Record<string, string> = {
|
|
8
|
+
login_success: '✓',
|
|
9
|
+
login_failed: '✗',
|
|
10
|
+
mfa_enabled: '🔒',
|
|
11
|
+
mfa_disabled: '🔓',
|
|
12
|
+
mfa_challenge_failed: '⚠',
|
|
13
|
+
password_changed: '🔑',
|
|
14
|
+
email_verified: '✉',
|
|
15
|
+
passkey_added: '🔐',
|
|
16
|
+
passkey_removed: '🗑',
|
|
17
|
+
magic_link_requested: '✉',
|
|
18
|
+
session_revoked: '⊗',
|
|
19
|
+
account_locked: '⛔',
|
|
20
|
+
user_created: '★',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const EVENT_LABELS: Record<string, string> = {
|
|
24
|
+
login_success: 'Signed in',
|
|
25
|
+
login_failed: 'Failed sign-in attempt',
|
|
26
|
+
mfa_enabled: '2FA enabled',
|
|
27
|
+
mfa_disabled: '2FA disabled',
|
|
28
|
+
mfa_challenge_failed: '2FA challenge failed',
|
|
29
|
+
password_changed: 'Password changed',
|
|
30
|
+
email_verified: 'Email verified',
|
|
31
|
+
passkey_added: 'Passkey added',
|
|
32
|
+
passkey_removed: 'Passkey removed',
|
|
33
|
+
magic_link_requested: 'Magic link sent',
|
|
34
|
+
session_revoked: 'Session revoked',
|
|
35
|
+
account_locked: 'Account locked',
|
|
36
|
+
user_created: 'Account created',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* /account/activity — Sign-in audit log for {{businessName}}.
|
|
41
|
+
*
|
|
42
|
+
* Immutable per-user record of auth events. Paginated table view
|
|
43
|
+
* with type icon, when, IP, user-agent, and country flag.
|
|
44
|
+
*/
|
|
45
|
+
export default function ActivityPage() {
|
|
46
|
+
const { list } = useAuditLog();
|
|
47
|
+
const [events, setEvents] = useState<AuditEvent[]>([]);
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
51
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
52
|
+
|
|
53
|
+
async function load(append = false) {
|
|
54
|
+
if (append) setLoadingMore(true); else setLoading(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
try {
|
|
57
|
+
const result = await list({ limit: 50, ...(append && cursor ? { cursor } : {}) });
|
|
58
|
+
setEvents((prev) => append ? [...prev, ...result.events] : result.events);
|
|
59
|
+
setCursor(result.nextCursor);
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
setError(err instanceof Error ? err.message : 'Failed to load activity');
|
|
62
|
+
} finally {
|
|
63
|
+
if (append) setLoadingMore(false); else setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
useEffect(() => { load(); }, []);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<main
|
|
71
|
+
style=\{{
|
|
72
|
+
maxWidth: '900px',
|
|
73
|
+
margin: '0 auto',
|
|
74
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
75
|
+
color: 'var(--fg, #111)',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
79
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
80
|
+
<a href="/account/security" style=\{{ color: 'var(--fg-muted, #666)', textDecoration: 'none', fontSize: '0.875rem' }}>
|
|
81
|
+
Security
|
|
82
|
+
</a>
|
|
83
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
84
|
+
<span style=\{{ fontSize: '0.875rem' }}>Activity</span>
|
|
85
|
+
</div>
|
|
86
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Sign-in activity</h1>
|
|
87
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
88
|
+
Immutable record of security events for your {{businessName}} account.
|
|
89
|
+
</p>
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
{error && (
|
|
93
|
+
<div style=\{{ padding: '0.75rem 1rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', color: '#dc2626', marginBottom: '1rem' }}>
|
|
94
|
+
{error}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{loading ? (
|
|
99
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading activity...</p>
|
|
100
|
+
) : events.length === 0 ? (
|
|
101
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>No activity found.</p>
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<div style=\{{ overflowX: 'auto' }}>
|
|
105
|
+
<table style=\{{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
106
|
+
<thead>
|
|
107
|
+
<tr style=\{{ borderBottom: '1px solid var(--border, #e5e7eb)', textAlign: 'left' }}>
|
|
108
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600, color: 'var(--fg-muted, #666)', width: '2rem' }}></th>
|
|
109
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Event</th>
|
|
110
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>When</th>
|
|
111
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>IP</th>
|
|
112
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Country</th>
|
|
113
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Device</th>
|
|
114
|
+
</tr>
|
|
115
|
+
</thead>
|
|
116
|
+
<tbody>
|
|
117
|
+
{events.map((evt, i) => {
|
|
118
|
+
const meta = evt.metadata as Record<string, string | undefined>;
|
|
119
|
+
const ua = meta?.userAgent || '';
|
|
120
|
+
const truncatedUa = ua.length > 50 ? ua.slice(0, 50) + '…' : ua;
|
|
121
|
+
return (
|
|
122
|
+
<tr
|
|
123
|
+
key={evt.sk || i}
|
|
124
|
+
style=\{{ borderBottom: '1px solid var(--border, #e5e7eb)' }}
|
|
125
|
+
>
|
|
126
|
+
<td style=\{{ padding: '0.6rem 0.75rem', textAlign: 'center', fontSize: '1rem' }}>
|
|
127
|
+
{EVENT_ICONS[evt.action] || '•'}
|
|
128
|
+
</td>
|
|
129
|
+
<td style=\{{ padding: '0.6rem 0.75rem', fontWeight: 500 }}>
|
|
130
|
+
{EVENT_LABELS[evt.action] || evt.action}
|
|
131
|
+
</td>
|
|
132
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', whiteSpace: 'nowrap' }}>
|
|
133
|
+
{new Date(evt.created_at).toLocaleString()}
|
|
134
|
+
</td>
|
|
135
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', fontFamily: 'monospace' }}>
|
|
136
|
+
{meta?.ipAddress || '—'}
|
|
137
|
+
</td>
|
|
138
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)' }}>
|
|
139
|
+
{meta?.country || '—'}
|
|
140
|
+
</td>
|
|
141
|
+
<td
|
|
142
|
+
style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
143
|
+
title={ua}
|
|
144
|
+
>
|
|
145
|
+
{truncatedUa || '—'}
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{cursor && (
|
|
155
|
+
<div style=\{{ marginTop: '1.5rem', textAlign: 'center' }}>
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => load(true)}
|
|
158
|
+
disabled={loadingMore}
|
|
159
|
+
style=\{{
|
|
160
|
+
padding: '0.5rem 1.5rem',
|
|
161
|
+
background: 'transparent',
|
|
162
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
163
|
+
borderRadius: '6px',
|
|
164
|
+
cursor: loadingMore ? 'not-allowed' : 'pointer',
|
|
165
|
+
fontSize: '0.875rem',
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{loadingMore ? 'Loading...' : 'Load more'}
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</main>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
import { useStorage, StorageDropzone } from '@githat/nextjs';
|
|
5
|
+
import type { StorageObject } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* /account/files — File storage for {{businessName}}.
|
|
9
|
+
*
|
|
10
|
+
* Lists uploaded files with download links. Uses <StorageDropzone/>
|
|
11
|
+
* for browser-direct uploads to S3 via the GitHat storage API.
|
|
12
|
+
* Works with Next.js static export — no server actions required.
|
|
13
|
+
*/
|
|
14
|
+
export default function FilesPage() {
|
|
15
|
+
const { list, getUrl, remove } = useStorage();
|
|
16
|
+
const [files, setFiles] = useState<StorageObject[]>([]);
|
|
17
|
+
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
async function load(cursor?: string) {
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const result = await list({ limit: 20, cursor });
|
|
27
|
+
if (cursor) {
|
|
28
|
+
setFiles((prev) => [...prev, ...result.items]);
|
|
29
|
+
} else {
|
|
30
|
+
setFiles(result.items);
|
|
31
|
+
}
|
|
32
|
+
setNextCursor(result.nextCursor);
|
|
33
|
+
} catch (err: unknown) {
|
|
34
|
+
setError(err instanceof Error ? err.message : 'Failed to load files');
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
useEffect(() => { load(); }, []);
|
|
41
|
+
|
|
42
|
+
async function handleDownload(objectId: string) {
|
|
43
|
+
try {
|
|
44
|
+
const obj = await getUrl(objectId);
|
|
45
|
+
if (obj.downloadUrl) {
|
|
46
|
+
window.open(obj.downloadUrl, '_blank', 'noopener,noreferrer');
|
|
47
|
+
}
|
|
48
|
+
} catch (err: unknown) {
|
|
49
|
+
setError(err instanceof Error ? err.message : 'Failed to get download URL');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleDelete(objectId: string) {
|
|
54
|
+
if (!confirm('Delete this file? This cannot be undone.')) return;
|
|
55
|
+
setDeletingId(objectId);
|
|
56
|
+
try {
|
|
57
|
+
await remove(objectId);
|
|
58
|
+
setFiles((prev) => prev.filter((f) => f.objectId !== objectId));
|
|
59
|
+
} catch (err: unknown) {
|
|
60
|
+
setError(err instanceof Error ? err.message : 'Failed to delete file');
|
|
61
|
+
} finally {
|
|
62
|
+
setDeletingId(null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatBytes(bytes: number): string {
|
|
67
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
68
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
69
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<main
|
|
74
|
+
style=\{{
|
|
75
|
+
maxWidth: '720px',
|
|
76
|
+
margin: '0 auto',
|
|
77
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
78
|
+
color: 'var(--fg, #111)',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
82
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Files</h1>
|
|
83
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
84
|
+
Upload and manage files in your {{businessName}} account.
|
|
85
|
+
</p>
|
|
86
|
+
</header>
|
|
87
|
+
|
|
88
|
+
{/* Upload dropzone */}
|
|
89
|
+
<StorageDropzone
|
|
90
|
+
accept="image/*,application/pdf,text/*"
|
|
91
|
+
onUpload={() => load()}
|
|
92
|
+
onError={(err) => setError(err.message)}
|
|
93
|
+
uploadOptions=\{{ isPublic: false }}
|
|
94
|
+
style=\{{ marginBottom: '1.5rem' }}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{error && (
|
|
98
|
+
<div
|
|
99
|
+
style=\{{
|
|
100
|
+
padding: '0.75rem 1rem',
|
|
101
|
+
background: '#fef2f2',
|
|
102
|
+
border: '1px solid #fecaca',
|
|
103
|
+
borderRadius: '6px',
|
|
104
|
+
color: '#dc2626',
|
|
105
|
+
marginBottom: '1rem',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{error}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{loading && files.length === 0 ? (
|
|
113
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading files...</p>
|
|
114
|
+
) : files.length === 0 ? (
|
|
115
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', textAlign: 'center', padding: '2rem 0' }}>
|
|
116
|
+
No files uploaded yet. Drop a file above to get started.
|
|
117
|
+
</p>
|
|
118
|
+
) : (
|
|
119
|
+
<div style=\{{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
|
120
|
+
{files.map((file) => (
|
|
121
|
+
<div
|
|
122
|
+
key={file.objectId}
|
|
123
|
+
style=\{{
|
|
124
|
+
display: 'flex',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
gap: '0.75rem',
|
|
127
|
+
padding: '0.875rem 1rem',
|
|
128
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
129
|
+
borderRadius: '8px',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{/* File icon */}
|
|
133
|
+
<svg
|
|
134
|
+
width="20"
|
|
135
|
+
height="20"
|
|
136
|
+
viewBox="0 0 24 24"
|
|
137
|
+
fill="none"
|
|
138
|
+
stroke="#9ca3af"
|
|
139
|
+
strokeWidth="1.5"
|
|
140
|
+
strokeLinecap="round"
|
|
141
|
+
strokeLinejoin="round"
|
|
142
|
+
style=\{{ flexShrink: 0 }}
|
|
143
|
+
aria-hidden
|
|
144
|
+
>
|
|
145
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
146
|
+
<polyline points="14 2 14 8 20 8" />
|
|
147
|
+
</svg>
|
|
148
|
+
|
|
149
|
+
{/* File info */}
|
|
150
|
+
<div style=\{{ flex: 1, minWidth: 0 }}>
|
|
151
|
+
<p
|
|
152
|
+
style=\{{
|
|
153
|
+
margin: 0,
|
|
154
|
+
fontWeight: 500,
|
|
155
|
+
fontSize: '0.9rem',
|
|
156
|
+
overflow: 'hidden',
|
|
157
|
+
textOverflow: 'ellipsis',
|
|
158
|
+
whiteSpace: 'nowrap',
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{file.objectKey}
|
|
162
|
+
</p>
|
|
163
|
+
<p style=\{{ margin: '0.125rem 0 0', fontSize: '0.78rem', color: 'var(--fg-muted, #666)' }}>
|
|
164
|
+
{formatBytes(file.size)} · {file.contentType} ·{' '}
|
|
165
|
+
{new Date(file.uploadedAt).toLocaleDateString()}
|
|
166
|
+
{file.isPublic && (
|
|
167
|
+
<span
|
|
168
|
+
style=\{{
|
|
169
|
+
marginLeft: '0.5rem',
|
|
170
|
+
padding: '0.1rem 0.4rem',
|
|
171
|
+
background: '#dcfce7',
|
|
172
|
+
color: '#16a34a',
|
|
173
|
+
borderRadius: '4px',
|
|
174
|
+
fontSize: '0.7rem',
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
Public
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Actions */}
|
|
185
|
+
<div style=\{{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => handleDownload(file.objectId)}
|
|
188
|
+
style=\{{
|
|
189
|
+
padding: '0.35rem 0.75rem',
|
|
190
|
+
fontSize: '0.825rem',
|
|
191
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
192
|
+
borderRadius: '6px',
|
|
193
|
+
background: 'transparent',
|
|
194
|
+
cursor: 'pointer',
|
|
195
|
+
}}
|
|
196
|
+
aria-label={`Download ${file.objectKey}`}
|
|
197
|
+
>
|
|
198
|
+
Download
|
|
199
|
+
</button>
|
|
200
|
+
<button
|
|
201
|
+
onClick={() => handleDelete(file.objectId)}
|
|
202
|
+
disabled={deletingId === file.objectId}
|
|
203
|
+
style=\{{
|
|
204
|
+
padding: '0.35rem 0.75rem',
|
|
205
|
+
fontSize: '0.825rem',
|
|
206
|
+
border: '1px solid #fecaca',
|
|
207
|
+
borderRadius: '6px',
|
|
208
|
+
background: 'transparent',
|
|
209
|
+
color: '#dc2626',
|
|
210
|
+
cursor: deletingId === file.objectId ? 'not-allowed' : 'pointer',
|
|
211
|
+
}}
|
|
212
|
+
aria-label={`Delete ${file.objectKey}`}
|
|
213
|
+
>
|
|
214
|
+
{deletingId === file.objectId ? '...' : 'Delete'}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
|
|
220
|
+
{nextCursor && (
|
|
221
|
+
<button
|
|
222
|
+
onClick={() => load(nextCursor)}
|
|
223
|
+
disabled={loading}
|
|
224
|
+
style=\{{
|
|
225
|
+
padding: '0.6rem 1.25rem',
|
|
226
|
+
marginTop: '0.5rem',
|
|
227
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
228
|
+
borderRadius: '6px',
|
|
229
|
+
background: 'transparent',
|
|
230
|
+
cursor: loading ? 'not-allowed' : 'pointer',
|
|
231
|
+
fontSize: '0.875rem',
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{loading ? 'Loading...' : 'Load more'}
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</main>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -26,7 +26,53 @@ export default function SecurityPage() {
|
|
|
26
26
|
Manage two-factor authentication for your {{businessName}} account.
|
|
27
27
|
</p>
|
|
28
28
|
</header>
|
|
29
|
+
|
|
29
30
|
<MfaManager />
|
|
31
|
+
|
|
32
|
+
<nav style=\{{ marginTop: '2.5rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
33
|
+
<a
|
|
34
|
+
href="/account/sessions"
|
|
35
|
+
style=\{{
|
|
36
|
+
display: 'flex',
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'space-between',
|
|
39
|
+
padding: '0.875rem 1.25rem',
|
|
40
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
41
|
+
borderRadius: '8px',
|
|
42
|
+
textDecoration: 'none',
|
|
43
|
+
color: 'var(--fg, #111)',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<div>
|
|
47
|
+
<div style=\{{ fontWeight: 600, fontSize: '0.9rem' }}>Active sessions</div>
|
|
48
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', marginTop: '0.125rem' }}>
|
|
49
|
+
See and revoke devices signed in to your account
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
53
|
+
</a>
|
|
54
|
+
<a
|
|
55
|
+
href="/account/activity"
|
|
56
|
+
style=\{{
|
|
57
|
+
display: 'flex',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'space-between',
|
|
60
|
+
padding: '0.875rem 1.25rem',
|
|
61
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
62
|
+
borderRadius: '8px',
|
|
63
|
+
textDecoration: 'none',
|
|
64
|
+
color: 'var(--fg, #111)',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<div>
|
|
68
|
+
<div style=\{{ fontWeight: 600, fontSize: '0.9rem' }}>Sign-in activity</div>
|
|
69
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', marginTop: '0.125rem' }}>
|
|
70
|
+
Review your account's sign-in history
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
74
|
+
</a>
|
|
75
|
+
</nav>
|
|
30
76
|
</main>
|
|
31
77
|
);
|
|
32
78
|
}
|