create-githat-app 1.8.5 → 1.8.6
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/security/page.tsx.hbs +46 -0
- package/templates/agent/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/base/README.md.hbs +76 -0
- package/templates/classroom/app/account/activity/page.tsx.hbs +176 -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/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/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/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/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/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/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/security/page.tsx.hbs +46 -0
- package/templates/saas/app/account/sessions/page.tsx.hbs +180 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useSessions } from '@githat/nextjs';
|
|
5
|
+
import type { Session } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* /account/sessions — Active session management for {{businessName}}.
|
|
9
|
+
*
|
|
10
|
+
* Lists all active sessions with device/IP/country info.
|
|
11
|
+
* Users can revoke individual sessions or sign out everywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export default function SessionsPage() {
|
|
14
|
+
const { list, revoke, revokeAll } = useSessions();
|
|
15
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [revoking, setRevoking] = useState<string | null>(null);
|
|
19
|
+
const [revokingAll, setRevokingAll] = useState(false);
|
|
20
|
+
|
|
21
|
+
async function load() {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
const { sessions: data } = await list();
|
|
26
|
+
setSessions(data);
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => { load(); }, []);
|
|
35
|
+
|
|
36
|
+
async function handleRevoke(sessionId: string) {
|
|
37
|
+
setRevoking(sessionId);
|
|
38
|
+
try {
|
|
39
|
+
await revoke(sessionId);
|
|
40
|
+
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
|
43
|
+
} finally {
|
|
44
|
+
setRevoking(null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleRevokeAll() {
|
|
49
|
+
if (!confirm('Sign out of all other devices?')) return;
|
|
50
|
+
setRevokingAll(true);
|
|
51
|
+
try {
|
|
52
|
+
await revokeAll();
|
|
53
|
+
setSessions((prev) => prev.filter((s) => s.current));
|
|
54
|
+
} catch (err: unknown) {
|
|
55
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
|
|
56
|
+
} finally {
|
|
57
|
+
setRevokingAll(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const otherCount = sessions.filter((s) => !s.current).length;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<main
|
|
65
|
+
style=\{{
|
|
66
|
+
maxWidth: '720px',
|
|
67
|
+
margin: '0 auto',
|
|
68
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
69
|
+
color: 'var(--fg, #111)',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
73
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
74
|
+
<a href="/account/security" style=\{{ color: 'var(--fg-muted, #666)', textDecoration: 'none', fontSize: '0.875rem' }}>
|
|
75
|
+
Security
|
|
76
|
+
</a>
|
|
77
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
78
|
+
<span style=\{{ fontSize: '0.875rem' }}>Active sessions</span>
|
|
79
|
+
</div>
|
|
80
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Active sessions</h1>
|
|
81
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
82
|
+
Devices signed in to your {{businessName}} account.
|
|
83
|
+
</p>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
{error && (
|
|
87
|
+
<div style=\{{ padding: '0.75rem 1rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', color: '#dc2626', marginBottom: '1rem' }}>
|
|
88
|
+
{error}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{otherCount > 0 && (
|
|
93
|
+
<div style=\{{ marginBottom: '1.5rem' }}>
|
|
94
|
+
<button
|
|
95
|
+
onClick={handleRevokeAll}
|
|
96
|
+
disabled={revokingAll}
|
|
97
|
+
style=\{{
|
|
98
|
+
padding: '0.5rem 1.25rem',
|
|
99
|
+
background: '#fef2f2',
|
|
100
|
+
color: '#dc2626',
|
|
101
|
+
border: '1px solid #fecaca',
|
|
102
|
+
borderRadius: '6px',
|
|
103
|
+
cursor: revokingAll ? 'not-allowed' : 'pointer',
|
|
104
|
+
fontWeight: 500,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{revokingAll ? 'Signing out...' : `Sign out everywhere else (${otherCount})`}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{loading ? (
|
|
113
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading sessions...</p>
|
|
114
|
+
) : sessions.length === 0 ? (
|
|
115
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>No active sessions found.</p>
|
|
116
|
+
) : (
|
|
117
|
+
<div style=\{{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
118
|
+
{sessions.map((session) => (
|
|
119
|
+
<div
|
|
120
|
+
key={session.id}
|
|
121
|
+
style=\{{
|
|
122
|
+
padding: '1rem 1.25rem',
|
|
123
|
+
border: session.current ? '1px solid #6366f1' : '1px solid var(--border, #e5e7eb)',
|
|
124
|
+
borderRadius: '8px',
|
|
125
|
+
display: 'flex',
|
|
126
|
+
justifyContent: 'space-between',
|
|
127
|
+
alignItems: 'flex-start',
|
|
128
|
+
gap: '1rem',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<div style=\{{ flex: 1, minWidth: 0 }}>
|
|
132
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
133
|
+
<span style=\{{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
134
|
+
{session.userAgent || 'Unknown device'}
|
|
135
|
+
</span>
|
|
136
|
+
{session.current && (
|
|
137
|
+
<span style=\{{
|
|
138
|
+
padding: '0.1rem 0.5rem',
|
|
139
|
+
background: '#ede9fe',
|
|
140
|
+
color: '#6366f1',
|
|
141
|
+
borderRadius: '999px',
|
|
142
|
+
fontSize: '0.75rem',
|
|
143
|
+
fontWeight: 600,
|
|
144
|
+
whiteSpace: 'nowrap',
|
|
145
|
+
}}>
|
|
146
|
+
This device
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
151
|
+
{session.ipAddress && <span>{session.ipAddress}</span>}
|
|
152
|
+
{session.country && <span>{session.country}</span>}
|
|
153
|
+
<span>Last active {new Date(session.lastActiveAt).toLocaleDateString()}</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{!session.current && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => handleRevoke(session.id)}
|
|
159
|
+
disabled={revoking === session.id}
|
|
160
|
+
style=\{{
|
|
161
|
+
padding: '0.375rem 0.875rem',
|
|
162
|
+
background: 'transparent',
|
|
163
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
164
|
+
borderRadius: '6px',
|
|
165
|
+
cursor: revoking === session.id ? 'not-allowed' : 'pointer',
|
|
166
|
+
fontSize: '0.875rem',
|
|
167
|
+
whiteSpace: 'nowrap',
|
|
168
|
+
flexShrink: 0,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{revoking === session.id ? 'Revoking...' : 'Revoke'}
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</main>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useSessions } from '@githat/nextjs';
|
|
5
|
+
import type { Session } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* /account/sessions — Active session management for {{businessName}}.
|
|
9
|
+
*
|
|
10
|
+
* Lists all active sessions with device/IP/country info.
|
|
11
|
+
* Users can revoke individual sessions or sign out everywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export default function SessionsPage() {
|
|
14
|
+
const { list, revoke, revokeAll } = useSessions();
|
|
15
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [revoking, setRevoking] = useState<string | null>(null);
|
|
19
|
+
const [revokingAll, setRevokingAll] = useState(false);
|
|
20
|
+
|
|
21
|
+
async function load() {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
const { sessions: data } = await list();
|
|
26
|
+
setSessions(data);
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => { load(); }, []);
|
|
35
|
+
|
|
36
|
+
async function handleRevoke(sessionId: string) {
|
|
37
|
+
setRevoking(sessionId);
|
|
38
|
+
try {
|
|
39
|
+
await revoke(sessionId);
|
|
40
|
+
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
|
43
|
+
} finally {
|
|
44
|
+
setRevoking(null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleRevokeAll() {
|
|
49
|
+
if (!confirm('Sign out of all other devices?')) return;
|
|
50
|
+
setRevokingAll(true);
|
|
51
|
+
try {
|
|
52
|
+
await revokeAll();
|
|
53
|
+
setSessions((prev) => prev.filter((s) => s.current));
|
|
54
|
+
} catch (err: unknown) {
|
|
55
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
|
|
56
|
+
} finally {
|
|
57
|
+
setRevokingAll(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const otherCount = sessions.filter((s) => !s.current).length;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<main
|
|
65
|
+
style=\{{
|
|
66
|
+
maxWidth: '720px',
|
|
67
|
+
margin: '0 auto',
|
|
68
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
69
|
+
color: 'var(--fg, #111)',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
73
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
74
|
+
<a href="/account/security" style=\{{ color: 'var(--fg-muted, #666)', textDecoration: 'none', fontSize: '0.875rem' }}>
|
|
75
|
+
Security
|
|
76
|
+
</a>
|
|
77
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
78
|
+
<span style=\{{ fontSize: '0.875rem' }}>Active sessions</span>
|
|
79
|
+
</div>
|
|
80
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Active sessions</h1>
|
|
81
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
82
|
+
Devices signed in to your {{businessName}} account.
|
|
83
|
+
</p>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
{error && (
|
|
87
|
+
<div style=\{{ padding: '0.75rem 1rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', color: '#dc2626', marginBottom: '1rem' }}>
|
|
88
|
+
{error}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{otherCount > 0 && (
|
|
93
|
+
<div style=\{{ marginBottom: '1.5rem' }}>
|
|
94
|
+
<button
|
|
95
|
+
onClick={handleRevokeAll}
|
|
96
|
+
disabled={revokingAll}
|
|
97
|
+
style=\{{
|
|
98
|
+
padding: '0.5rem 1.25rem',
|
|
99
|
+
background: '#fef2f2',
|
|
100
|
+
color: '#dc2626',
|
|
101
|
+
border: '1px solid #fecaca',
|
|
102
|
+
borderRadius: '6px',
|
|
103
|
+
cursor: revokingAll ? 'not-allowed' : 'pointer',
|
|
104
|
+
fontWeight: 500,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{revokingAll ? 'Signing out...' : `Sign out everywhere else (${otherCount})`}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{loading ? (
|
|
113
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading sessions...</p>
|
|
114
|
+
) : sessions.length === 0 ? (
|
|
115
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>No active sessions found.</p>
|
|
116
|
+
) : (
|
|
117
|
+
<div style=\{{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
118
|
+
{sessions.map((session) => (
|
|
119
|
+
<div
|
|
120
|
+
key={session.id}
|
|
121
|
+
style=\{{
|
|
122
|
+
padding: '1rem 1.25rem',
|
|
123
|
+
border: session.current ? '1px solid #6366f1' : '1px solid var(--border, #e5e7eb)',
|
|
124
|
+
borderRadius: '8px',
|
|
125
|
+
display: 'flex',
|
|
126
|
+
justifyContent: 'space-between',
|
|
127
|
+
alignItems: 'flex-start',
|
|
128
|
+
gap: '1rem',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<div style=\{{ flex: 1, minWidth: 0 }}>
|
|
132
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
133
|
+
<span style=\{{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
134
|
+
{session.userAgent || 'Unknown device'}
|
|
135
|
+
</span>
|
|
136
|
+
{session.current && (
|
|
137
|
+
<span style=\{{
|
|
138
|
+
padding: '0.1rem 0.5rem',
|
|
139
|
+
background: '#ede9fe',
|
|
140
|
+
color: '#6366f1',
|
|
141
|
+
borderRadius: '999px',
|
|
142
|
+
fontSize: '0.75rem',
|
|
143
|
+
fontWeight: 600,
|
|
144
|
+
whiteSpace: 'nowrap',
|
|
145
|
+
}}>
|
|
146
|
+
This device
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
151
|
+
{session.ipAddress && <span>{session.ipAddress}</span>}
|
|
152
|
+
{session.country && <span>{session.country}</span>}
|
|
153
|
+
<span>Last active {new Date(session.lastActiveAt).toLocaleDateString()}</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{!session.current && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => handleRevoke(session.id)}
|
|
159
|
+
disabled={revoking === session.id}
|
|
160
|
+
style=\{{
|
|
161
|
+
padding: '0.375rem 0.875rem',
|
|
162
|
+
background: 'transparent',
|
|
163
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
164
|
+
borderRadius: '6px',
|
|
165
|
+
cursor: revoking === session.id ? 'not-allowed' : 'pointer',
|
|
166
|
+
fontSize: '0.875rem',
|
|
167
|
+
whiteSpace: 'nowrap',
|
|
168
|
+
flexShrink: 0,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{revoking === session.id ? 'Revoking...' : 'Revoke'}
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</main>
|
|
179
|
+
);
|
|
180
|
+
}
|