html_templates
migrations
src
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
52
56
-webkit-font-smoothing: antialiased;
53
57
}
54
58
55
55
-
.layout { display: flex; min-height: 100vh; }
59
59
+
.layout {
60
60
+
display: flex;
61
61
+
min-height: 100vh;
62
62
+
}
63
63
+
64
64
+
.sidebar {
65
65
+
width: 220px;
66
66
+
background: var(--bg-primary-color);
67
67
+
border-right: 1px solid var(--border-color);
68
68
+
padding: 20px 0;
69
69
+
position: fixed;
70
70
+
top: 0;
71
71
+
left: 0;
72
72
+
bottom: 0;
73
73
+
overflow-y: auto;
74
74
+
display: flex;
75
75
+
flex-direction: column;
76
76
+
}
77
77
+
78
78
+
.sidebar-title {
79
79
+
font-size: 0.8125rem;
80
80
+
font-weight: 700;
81
81
+
padding: 0 20px;
82
82
+
margin-bottom: 4px;
83
83
+
white-space: nowrap;
84
84
+
overflow: hidden;
85
85
+
text-overflow: ellipsis;
86
86
+
}
87
87
+
88
88
+
.sidebar-subtitle {
89
89
+
font-size: 0.6875rem;
90
90
+
color: var(--secondary-color);
91
91
+
padding: 0 20px;
92
92
+
margin-bottom: 20px;
93
93
+
}
94
94
+
95
95
+
.sidebar nav {
96
96
+
flex: 1;
97
97
+
}
98
98
+
99
99
+
.sidebar nav a {
100
100
+
display: block;
101
101
+
padding: 8px 20px;
102
102
+
font-size: 0.8125rem;
103
103
+
color: var(--secondary-color);
104
104
+
text-decoration: none;
105
105
+
transition: background 0.1s, color 0.1s;
106
106
+
}
107
107
+
108
108
+
.sidebar nav a:hover {
109
109
+
background: var(--bg-secondary-color);
110
110
+
color: var(--primary-color);
111
111
+
}
112
112
+
113
113
+
.sidebar nav a.active {
114
114
+
color: var(--brand-color);
115
115
+
font-weight: 500;
116
116
+
}
117
117
+
118
118
+
.sidebar-footer {
119
119
+
padding: 16px 20px 0;
120
120
+
border-top: 1px solid var(--border-color);
121
121
+
margin-top: 16px;
122
122
+
}
123
123
+
124
124
+
.sidebar-footer .session-info {
125
125
+
font-size: 0.75rem;
126
126
+
color: var(--secondary-color);
127
127
+
margin-bottom: 8px;
128
128
+
}
129
129
+
130
130
+
.sidebar-footer form {
131
131
+
display: inline;
132
132
+
}
133
133
+
134
134
+
.sidebar-footer button {
135
135
+
background: none;
136
136
+
border: none;
137
137
+
font-size: 0.75rem;
138
138
+
color: var(--secondary-color);
139
139
+
cursor: pointer;
140
140
+
padding: 0;
141
141
+
text-decoration: underline;
142
142
+
}
143
143
+
144
144
+
.sidebar-footer button:hover {
145
145
+
color: var(--primary-color);
146
146
+
}
147
147
+
148
148
+
.main {
149
149
+
margin-left: 220px;
150
150
+
flex: 1;
151
151
+
padding: 32px;
152
152
+
max-width: 960px;
153
153
+
}
154
154
+
155
155
+
.page-title {
156
156
+
font-size: 1.5rem;
157
157
+
font-weight: 700;
158
158
+
margin-bottom: 4px;
159
159
+
}
160
160
+
161
161
+
.page-subtitle {
162
162
+
font-size: 0.8125rem;
163
163
+
color: var(--secondary-color);
164
164
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
165
165
+
margin-bottom: 24px;
166
166
+
word-break: break-all;
167
167
+
}
168
168
+
169
169
+
.flash-success {
170
170
+
background: rgba(22, 163, 74, 0.1);
171
171
+
color: var(--success-color);
172
172
+
border: 1px solid rgba(22, 163, 74, 0.2);
173
173
+
border-radius: 8px;
174
174
+
padding: 10px 14px;
175
175
+
font-size: 0.875rem;
176
176
+
margin-bottom: 20px;
177
177
+
}
178
178
+
179
179
+
.flash-error {
180
180
+
background: rgba(220, 38, 38, 0.1);
181
181
+
color: var(--danger-color);
182
182
+
border: 1px solid rgba(220, 38, 38, 0.2);
183
183
+
border-radius: 8px;
184
184
+
padding: 10px 14px;
185
185
+
font-size: 0.875rem;
186
186
+
margin-bottom: 20px;
187
187
+
}
188
188
+
189
189
+
.detail-section {
190
190
+
background: var(--bg-primary-color);
191
191
+
border: 1px solid var(--border-color);
192
192
+
border-radius: 10px;
193
193
+
padding: 20px;
194
194
+
margin-bottom: 16px;
195
195
+
}
196
196
+
197
197
+
.detail-section h3 {
198
198
+
font-size: 0.875rem;
199
199
+
font-weight: 600;
200
200
+
margin-bottom: 12px;
201
201
+
}
202
202
+
203
203
+
.detail-row {
204
204
+
display: flex;
205
205
+
justify-content: space-between;
206
206
+
align-items: center;
207
207
+
padding: 8px 0;
208
208
+
font-size: 0.8125rem;
209
209
+
border-bottom: 1px solid var(--border-color);
210
210
+
}
56
211
57
57
-
.sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; }
58
58
-
.sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
59
59
-
.sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; }
60
60
-
.sidebar nav { flex: 1; }
61
61
-
.sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; }
62
62
-
.sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); }
63
63
-
.sidebar nav a.active { color: var(--brand-color); font-weight: 500; }
64
64
-
.sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; }
65
65
-
.sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; }
66
66
-
.sidebar-footer form { display: inline; }
67
67
-
.sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; }
68
68
-
.sidebar-footer button:hover { color: var(--primary-color); }
212
212
+
.detail-row:last-child {
213
213
+
border-bottom: none;
214
214
+
}
69
215
70
70
-
.main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; }
71
71
-
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; }
72
72
-
.page-subtitle { font-size: 0.8125rem; color: var(--secondary-color); font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; margin-bottom: 24px; word-break: break-all; }
216
216
+
.detail-row .label {
217
217
+
color: var(--secondary-color);
218
218
+
flex-shrink: 0;
219
219
+
}
73
220
74
74
-
.flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
75
75
-
.flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
221
221
+
.detail-row .value {
222
222
+
font-weight: 500;
223
223
+
word-break: break-all;
224
224
+
text-align: right;
225
225
+
max-width: 65%;
226
226
+
}
76
227
77
77
-
.detail-section { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
78
78
-
.detail-section h3 { font-size: 0.875rem; font-weight: 600; margin-bottom: 12px; }
79
79
-
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); }
80
80
-
.detail-row:last-child { border-bottom: none; }
81
81
-
.detail-row .label { color: var(--secondary-color); flex-shrink: 0; }
82
82
-
.detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; }
228
228
+
.badge {
229
229
+
display: inline-block;
230
230
+
padding: 2px 8px;
231
231
+
border-radius: 4px;
232
232
+
font-size: 0.75rem;
233
233
+
font-weight: 500;
234
234
+
}
83
235
84
84
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
85
85
-
.badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); }
86
86
-
.badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); }
87
87
-
.badge-warning { background: rgba(234, 179, 8, 0.1); color: var(--warning-color); }
236
236
+
.badge-success {
237
237
+
background: rgba(22, 163, 74, 0.1);
238
238
+
color: var(--success-color);
239
239
+
}
88
240
89
89
-
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
90
90
-
.actions form { display: inline; }
241
241
+
.badge-danger {
242
242
+
background: rgba(220, 38, 38, 0.1);
243
243
+
color: var(--danger-color);
244
244
+
}
91
245
92
92
-
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; font-size: 0.8125rem; font-weight: 500; border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; background: var(--bg-primary-color); color: var(--primary-color); }
93
93
-
.btn:hover { opacity: 0.85; }
94
94
-
.btn-primary { background: var(--brand-color); color: #fff; border-color: var(--brand-color); }
95
95
-
.btn-danger { background: var(--danger-color); color: #fff; border-color: var(--danger-color); }
96
96
-
.btn-warning { background: var(--warning-color); color: #000; border-color: var(--warning-color); }
246
246
+
.badge-warning {
247
247
+
background: rgba(234, 179, 8, 0.1);
248
248
+
color: var(--warning-color);
249
249
+
}
97
250
98
98
-
.password-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; }
99
99
-
.password-box .pw-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; }
100
100
-
.password-box .pw-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; }
251
251
+
.actions {
252
252
+
display: flex;
253
253
+
flex-wrap: wrap;
254
254
+
gap: 8px;
255
255
+
margin-top: 8px;
256
256
+
}
101
257
102
102
-
.back-link { display: inline-block; color: var(--brand-color); text-decoration: none; font-size: 0.8125rem; margin-bottom: 16px; }
103
103
-
.back-link:hover { text-decoration: underline; }
258
258
+
.actions form {
259
259
+
display: inline;
260
260
+
}
261
261
+
262
262
+
.btn {
263
263
+
display: inline-flex;
264
264
+
align-items: center;
265
265
+
justify-content: center;
266
266
+
padding: 8px 16px;
267
267
+
font-size: 0.8125rem;
268
268
+
font-weight: 500;
269
269
+
border: 1px solid var(--border-color);
270
270
+
border-radius: 8px;
271
271
+
cursor: pointer;
272
272
+
transition: opacity 0.15s;
273
273
+
text-decoration: none;
274
274
+
background: var(--bg-primary-color);
275
275
+
color: var(--primary-color);
276
276
+
}
277
277
+
278
278
+
.btn:hover {
279
279
+
opacity: 0.85;
280
280
+
}
281
281
+
282
282
+
.btn-primary {
283
283
+
background: var(--brand-color);
284
284
+
color: #fff;
285
285
+
border-color: var(--brand-color);
286
286
+
}
287
287
+
288
288
+
.btn-danger {
289
289
+
background: var(--danger-color);
290
290
+
color: #fff;
291
291
+
border-color: var(--danger-color);
292
292
+
}
293
293
+
294
294
+
.btn-warning {
295
295
+
background: var(--warning-color);
296
296
+
color: #000;
297
297
+
border-color: var(--warning-color);
298
298
+
}
104
299
105
105
-
.collection-list { max-height: 200px; overflow-y: auto; padding: 8px 0; }
106
106
-
.collection-item { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); border-bottom: 1px solid var(--border-color); }
107
107
-
.collection-item:last-child { border-bottom: none; }
300
300
+
.password-box {
301
301
+
background: rgba(22, 163, 74, 0.08);
302
302
+
border: 1px solid rgba(22, 163, 74, 0.2);
303
303
+
border-radius: 10px;
304
304
+
padding: 16px 20px;
305
305
+
margin-bottom: 16px;
306
306
+
}
108
307
109
109
-
.threat-sig { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); }
308
308
+
.password-box .pw-label {
309
309
+
font-size: 0.75rem;
310
310
+
font-weight: 600;
311
311
+
color: var(--success-color);
312
312
+
margin-bottom: 6px;
313
313
+
}
314
314
+
315
315
+
.password-box .pw-value {
316
316
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
317
317
+
font-size: 1rem;
318
318
+
font-weight: 600;
319
319
+
user-select: all;
320
320
+
}
321
321
+
322
322
+
.back-link {
323
323
+
display: inline-block;
324
324
+
color: var(--brand-color);
325
325
+
text-decoration: none;
326
326
+
font-size: 0.8125rem;
327
327
+
margin-bottom: 16px;
328
328
+
}
329
329
+
330
330
+
.back-link:hover {
331
331
+
text-decoration: underline;
332
332
+
}
333
333
+
334
334
+
.collection-list {
335
335
+
max-height: 200px;
336
336
+
overflow-y: auto;
337
337
+
padding: 8px 0;
338
338
+
}
339
339
+
340
340
+
.collection-item {
341
341
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
342
342
+
font-size: 0.75rem;
343
343
+
padding: 4px 0;
344
344
+
color: var(--secondary-color);
345
345
+
border-bottom: 1px solid var(--border-color);
346
346
+
}
347
347
+
348
348
+
.collection-item:last-child {
349
349
+
border-bottom: none;
350
350
+
}
351
351
+
352
352
+
.threat-sig {
353
353
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
354
354
+
font-size: 0.75rem;
355
355
+
padding: 4px 0;
356
356
+
color: var(--secondary-color);
357
357
+
}
110
358
111
359
@media (max-width: 768px) {
112
112
-
.sidebar { display: none; }
113
113
-
.main { margin-left: 0; }
360
360
+
.sidebar {
361
361
+
display: none;
362
362
+
}
363
363
+
364
364
+
.main {
365
365
+
margin-left: 0;
366
366
+
}
114
367
}
115
368
</style>
116
369
</head>
117
370
<body>
118
118
-
<div class="layout">
119
119
-
<aside class="sidebar">
120
120
-
<div class="sidebar-title">{{pds_hostname}}</div>
121
121
-
<div class="sidebar-subtitle">Admin Portal</div>
122
122
-
<nav>
123
123
-
<a href="/admin/">Dashboard</a>
124
124
-
{{#if can_view_accounts}}
371
371
+
<div class="layout">
372
372
+
<aside class="sidebar">
373
373
+
<div class="sidebar-title">{{pds_hostname}}</div>
374
374
+
<div class="sidebar-subtitle">Admin Portal</div>
375
375
+
<nav>
376
376
+
<a href="/admin/dashboard">Dashboard</a>
377
377
+
{{#if can_view_accounts}}
125
378
<a href="/admin/accounts" class="active">Accounts</a>
126
126
-
{{/if}}
127
127
-
{{#if can_manage_invites}}
379
379
+
{{/if}}
380
380
+
{{#if can_manage_invites}}
128
381
<a href="/admin/invite-codes">Invite Codes</a>
129
129
-
{{/if}}
130
130
-
{{#if can_create_account}}
382
382
+
{{/if}}
383
383
+
{{#if can_create_account}}
131
384
<a href="/admin/create-account">Create Account</a>
132
132
-
{{/if}}
133
133
-
{{#if can_request_crawl}}
385
385
+
{{/if}}
386
386
+
{{#if can_request_crawl}}
134
387
<a href="/admin/request-crawl">Request Crawl</a>
135
135
-
{{/if}}
136
136
-
</nav>
137
137
-
<div class="sidebar-footer">
138
138
-
<div class="session-info">Signed in as {{handle}}</div>
139
139
-
<form method="POST" action="/admin/logout">
140
140
-
<button type="submit">Sign out</button>
141
141
-
</form>
142
142
-
</div>
143
143
-
</aside>
388
388
+
{{/if}}
389
389
+
</nav>
390
390
+
<div class="sidebar-footer">
391
391
+
<div class="session-info">Signed in as {{handle}}</div>
392
392
+
<form method="POST" action="/admin/logout">
393
393
+
<button type="submit">Sign out</button>
394
394
+
</form>
395
395
+
</div>
396
396
+
</aside>
144
397
145
145
-
<main class="main">
146
146
-
{{#if flash_success}}
398
398
+
<main class="main">
399
399
+
{{#if flash_success}}
147
400
<div class="flash-success">{{flash_success}}</div>
148
148
-
{{/if}}
149
149
-
{{#if flash_error}}
401
401
+
{{/if}}
402
402
+
{{#if flash_error}}
150
403
<div class="flash-error">{{flash_error}}</div>
151
151
-
{{/if}}
404
404
+
{{/if}}
152
405
153
153
-
<a href="/admin/accounts" class="back-link">← Back to Accounts</a>
406
406
+
<a href="/admin/accounts" class="back-link">← Back to Accounts</a>
154
407
155
155
-
<h1 class="page-title">{{account.handle}}</h1>
156
156
-
<div class="page-subtitle">{{account.did}}</div>
408
408
+
<h1 class="page-title">{{account.handle}}</h1>
409
409
+
<div class="page-subtitle">{{account.did}}</div>
157
410
158
158
-
{{#if new_password}}
411
411
+
{{#if new_password}}
159
412
<div class="password-box">
160
413
<div class="pw-label">New Password (copy now -- it will not be shown again)</div>
161
414
<div class="pw-value">{{new_password}}</div>
162
415
</div>
163
163
-
{{/if}}
416
416
+
{{/if}}
164
417
165
165
-
<div class="detail-section">
166
166
-
<h3>Account Information</h3>
167
167
-
<div class="detail-row">
168
168
-
<span class="label">Handle</span>
169
169
-
<span class="value">{{account.handle}}</span>
170
170
-
</div>
171
171
-
<div class="detail-row">
172
172
-
<span class="label">DID</span>
173
173
-
<span class="value">{{account.did}}</span>
174
174
-
</div>
175
175
-
<div class="detail-row">
176
176
-
<span class="label">Email</span>
177
177
-
<span class="value">{{account.email}}</span>
178
178
-
</div>
179
179
-
{{#if account.indexedAt}}
418
418
+
<div class="detail-section">
419
419
+
<h3>Account Information</h3>
420
420
+
<div class="detail-row">
421
421
+
<span class="label">Handle</span>
422
422
+
<span class="value">{{account.handle}}</span>
423
423
+
</div>
424
424
+
<div class="detail-row">
425
425
+
<span class="label">DID</span>
426
426
+
<span class="value">{{account.did}}</span>
427
427
+
</div>
428
428
+
<div class="detail-row">
429
429
+
<span class="label">Email</span>
430
430
+
<span class="value">{{account.email}}</span>
431
431
+
</div>
432
432
+
{{#if account.indexedAt}}
180
433
<div class="detail-row">
181
434
<span class="label">Indexed At</span>
182
435
<span class="value">{{account.indexedAt}}</span>
183
436
</div>
184
184
-
{{/if}}
185
185
-
{{#if handle_resolution_checked}}
437
437
+
{{/if}}
438
438
+
{{#if handle_resolution_checked}}
186
439
<div class="detail-row">
187
440
<span class="label">Handle Resolution</span>
188
441
<span class="value">
189
442
{{#if handle_is_correct}}
190
190
-
<span class="badge badge-success">Valid</span>
443
443
+
<span class="badge badge-success">Valid</span>
191
444
{{else}}
192
192
-
<span class="badge badge-danger">Failed</span>
445
445
+
<span class="badge badge-danger">Failed</span>
193
446
{{/if}}
194
447
</span>
195
448
</div>
196
196
-
{{/if}}
197
197
-
</div>
449
449
+
{{/if}}
450
450
+
</div>
198
451
199
199
-
<div class="detail-section">
200
200
-
<h3>Status</h3>
201
201
-
<div class="detail-row">
202
202
-
<span class="label">Takedown</span>
203
203
-
<span class="value">
204
204
-
{{#if is_taken_down}}
452
452
+
<div class="detail-section">
453
453
+
<h3>Status</h3>
454
454
+
<div class="detail-row">
455
455
+
<span class="label">Takedown</span>
456
456
+
<span class="value">
457
457
+
{{#if is_taken_down}}
205
458
<span class="badge badge-danger">Taken Down</span>
206
206
-
{{else}}
459
459
+
{{else}}
207
460
<span class="badge badge-success">Active</span>
208
208
-
{{/if}}
209
209
-
</span>
210
210
-
</div>
211
211
-
{{#if takedown_ref}}
461
461
+
{{/if}}
462
462
+
</span>
463
463
+
</div>
464
464
+
{{#if takedown_ref}}
212
465
<div class="detail-row">
213
466
<span class="label">Takedown Reference</span>
214
467
<span class="value">{{takedown_ref}}</span>
215
468
</div>
216
216
-
{{/if}}
217
217
-
<div class="detail-row">
218
218
-
<span class="label">Email Confirmed</span>
219
219
-
<span class="value">
220
220
-
{{#if account.emailConfirmedAt}}
469
469
+
{{/if}}
470
470
+
<div class="detail-row">
471
471
+
<span class="label">Email Confirmed</span>
472
472
+
<span class="value">
473
473
+
{{#if account.emailConfirmedAt}}
221
474
<span class="badge badge-success">Confirmed</span>
222
222
-
{{else}}
475
475
+
{{else}}
223
476
<span class="badge badge-warning">Unconfirmed</span>
224
224
-
{{/if}}
225
225
-
</span>
226
226
-
</div>
227
227
-
<div class="detail-row">
228
228
-
<span class="label">Deactivated</span>
229
229
-
<span class="value">
230
230
-
{{#if account.deactivatedAt}}
477
477
+
{{/if}}
478
478
+
</span>
479
479
+
</div>
480
480
+
<div class="detail-row">
481
481
+
<span class="label">Deactivated</span>
482
482
+
<span class="value">
483
483
+
{{#if account.deactivatedAt}}
231
484
<span class="badge badge-warning">{{account.deactivatedAt}}</span>
232
232
-
{{else}}
485
485
+
{{else}}
233
486
<span class="badge badge-success">No</span>
234
234
-
{{/if}}
235
235
-
</span>
236
236
-
</div>
487
487
+
{{/if}}
488
488
+
</span>
237
489
</div>
490
490
+
</div>
238
491
239
239
-
{{#if repo_status_checked}}
492
492
+
{{#if repo_status_checked}}
240
493
<div class="detail-section">
241
494
<h3>Repo Status</h3>
242
495
<div class="detail-row">
243
496
<span class="label">Active</span>
244
497
<span class="value">
245
498
{{#if repo_active}}
246
246
-
<span class="badge badge-success">Active</span>
499
499
+
<span class="badge badge-success">Active</span>
247
500
{{else}}
248
248
-
<span class="badge badge-danger">Inactive</span>
501
501
+
<span class="badge badge-danger">Inactive</span>
249
502
{{/if}}
250
503
</span>
251
504
</div>
252
505
{{#if repo_status_reason}}
253
253
-
<div class="detail-row">
254
254
-
<span class="label">Status Reason</span>
255
255
-
<span class="value">{{repo_status_reason}}</span>
256
256
-
</div>
506
506
+
<div class="detail-row">
507
507
+
<span class="label">Status Reason</span>
508
508
+
<span class="value">{{repo_status_reason}}</span>
509
509
+
</div>
257
510
{{/if}}
258
511
{{#if repo_rev}}
259
259
-
<div class="detail-row">
260
260
-
<span class="label">Revision</span>
261
261
-
<span class="value" style="font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem;">{{repo_rev}}</span>
262
262
-
</div>
512
512
+
<div class="detail-row">
513
513
+
<span class="label">Revision</span>
514
514
+
<span class="value"
515
515
+
style="font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem;">{{repo_rev}}</span>
516
516
+
</div>
263
517
{{/if}}
264
518
</div>
265
265
-
{{/if}}
519
519
+
{{/if}}
266
520
267
267
-
<div class="detail-section">
268
268
-
<h3>Invite Information</h3>
269
269
-
{{#if account.invitedBy}}
521
521
+
<div class="detail-section">
522
522
+
<h3>Invite Information</h3>
523
523
+
{{#if account.invitedBy}}
270
524
<div class="detail-row">
271
525
<span class="label">Invited By</span>
272
526
<span class="value">{{account.invitedBy}}</span>
273
527
</div>
274
274
-
{{/if}}
275
275
-
<div class="detail-row">
276
276
-
<span class="label">Invites Disabled</span>
277
277
-
<span class="value">
278
278
-
{{#if account.invitesDisabled}}
528
528
+
{{/if}}
529
529
+
<div class="detail-row">
530
530
+
<span class="label">Invites Disabled</span>
531
531
+
<span class="value">
532
532
+
{{#if account.invitesDisabled}}
279
533
<span class="badge badge-danger">Yes</span>
280
280
-
{{else}}
534
534
+
{{else}}
281
535
<span class="badge badge-success">No</span>
282
282
-
{{/if}}
283
283
-
</span>
284
284
-
</div>
285
285
-
{{#if account.inviteNote}}
536
536
+
{{/if}}
537
537
+
</span>
538
538
+
</div>
539
539
+
{{#if account.inviteNote}}
286
540
<div class="detail-row">
287
541
<span class="label">Invite Note</span>
288
542
<span class="value">{{account.inviteNote}}</span>
289
543
</div>
290
290
-
{{/if}}
291
291
-
</div>
544
544
+
{{/if}}
545
545
+
</div>
292
546
293
293
-
{{#if collections}}
547
547
+
{{#if collections}}
294
548
<div class="detail-section">
295
549
<h3>Collections</h3>
296
550
<div class="collection-list">
297
551
{{#each collections}}
298
298
-
<div class="collection-item">{{this}}</div>
552
552
+
<div class="collection-item">{{this}}</div>
299
553
{{/each}}
300
554
</div>
301
555
</div>
302
302
-
{{/if}}
556
556
+
{{/if}}
303
557
304
304
-
{{#if threat_signatures}}
558
558
+
{{#if threat_signatures}}
305
559
<div class="detail-section">
306
560
<h3>Threat Signatures</h3>
307
561
{{#each threat_signatures}}
308
308
-
<div class="threat-sig">{{this.property}}: {{this.value}}</div>
562
562
+
<div class="threat-sig">{{this.property}}: {{this.value}}</div>
309
563
{{/each}}
310
564
</div>
311
311
-
{{/if}}
565
565
+
{{/if}}
312
566
313
313
-
<div class="detail-section">
314
314
-
<h3>Actions</h3>
315
315
-
<div class="actions">
316
316
-
{{#if can_manage_takedowns}}
317
317
-
{{#if is_taken_down}}
567
567
+
<div class="detail-section">
568
568
+
<h3>Actions</h3>
569
569
+
<div class="actions">
570
570
+
{{#if can_manage_takedowns}}
571
571
+
{{#if is_taken_down}}
318
572
<form method="POST" action="/admin/accounts/{{account.did}}/untakedown">
319
573
<button type="submit" class="btn btn-primary">Remove Takedown</button>
320
574
</form>
321
321
-
{{else}}
575
575
+
{{else}}
322
576
<form method="POST" action="/admin/accounts/{{account.did}}/takedown">
323
577
<button type="submit" class="btn btn-warning">Takedown Account</button>
324
578
</form>
325
325
-
{{/if}}
326
579
{{/if}}
580
580
+
{{/if}}
327
581
328
328
-
{{#if can_reset_password}}
329
329
-
<form method="POST" action="/admin/accounts/{{account.did}}/reset-password" onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');">
582
582
+
{{#if can_reset_password}}
583
583
+
<form method="POST" action="/admin/accounts/{{account.did}}/reset-password"
584
584
+
onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');">
330
585
<button type="submit" class="btn">Reset Password</button>
331
586
</form>
332
332
-
{{/if}}
587
587
+
{{/if}}
333
588
334
334
-
{{#if can_manage_invites}}
335
335
-
{{#if account.invitesDisabled}}
589
589
+
{{#if can_manage_invites}}
590
590
+
{{#if account.invitesDisabled}}
336
591
<form method="POST" action="/admin/accounts/{{account.did}}/enable-invites">
337
592
<button type="submit" class="btn">Enable Invites</button>
338
593
</form>
339
339
-
{{else}}
594
594
+
{{else}}
340
595
<form method="POST" action="/admin/accounts/{{account.did}}/disable-invites">
341
596
<button type="submit" class="btn">Disable Invites</button>
342
597
</form>
343
343
-
{{/if}}
344
598
{{/if}}
599
599
+
{{/if}}
345
600
346
346
-
{{#if can_delete_account}}
347
347
-
<form method="POST" action="/admin/accounts/{{account.did}}/delete" onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');">
601
601
+
{{#if can_delete_account}}
602
602
+
<form method="POST" action="/admin/accounts/{{account.did}}/delete"
603
603
+
onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');">
348
604
<button type="submit" class="btn btn-danger">Delete Account</button>
349
605
</form>
350
350
-
{{/if}}
351
351
-
</div>
606
606
+
{{/if}}
352
607
</div>
353
353
-
</main>
354
354
-
</div>
608
608
+
</div>
609
609
+
</main>
610
610
+
</div>
355
611
356
612
</body>
357
613
</html>
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
52
56
-webkit-font-smoothing: antialiased;
53
57
}
54
58
55
55
-
.layout { display: flex; min-height: 100vh; }
59
59
+
.layout {
60
60
+
display: flex;
61
61
+
min-height: 100vh;
62
62
+
}
56
63
57
64
.sidebar {
58
65
width: 220px;
···
60
67
border-right: 1px solid var(--border-color);
61
68
padding: 20px 0;
62
69
position: fixed;
63
63
-
top: 0; left: 0; bottom: 0;
70
70
+
top: 0;
71
71
+
left: 0;
72
72
+
bottom: 0;
64
73
overflow-y: auto;
65
74
display: flex;
66
75
flex-direction: column;
67
76
}
68
77
69
69
-
.sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
70
70
-
.sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; }
71
71
-
.sidebar nav { flex: 1; }
72
72
-
.sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; }
73
73
-
.sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); }
74
74
-
.sidebar nav a.active { color: var(--brand-color); font-weight: 500; }
75
75
-
.sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; }
76
76
-
.sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; }
77
77
-
.sidebar-footer form { display: inline; }
78
78
-
.sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; }
79
79
-
.sidebar-footer button:hover { color: var(--primary-color); }
78
78
+
.sidebar-title {
79
79
+
font-size: 0.8125rem;
80
80
+
font-weight: 700;
81
81
+
padding: 0 20px;
82
82
+
margin-bottom: 4px;
83
83
+
white-space: nowrap;
84
84
+
overflow: hidden;
85
85
+
text-overflow: ellipsis;
86
86
+
}
80
87
81
81
-
.main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; }
82
82
-
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; }
88
88
+
.sidebar-subtitle {
89
89
+
font-size: 0.6875rem;
90
90
+
color: var(--secondary-color);
91
91
+
padding: 0 20px;
92
92
+
margin-bottom: 20px;
93
93
+
}
83
94
84
84
-
.flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
85
85
-
.flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
95
95
+
.sidebar nav {
96
96
+
flex: 1;
97
97
+
}
86
98
87
87
-
.search-form { display: flex; gap: 8px; margin-bottom: 24px; }
88
88
-
.search-form input { flex: 1; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; }
89
89
-
.search-form input:focus { border-color: var(--brand-color); }
90
90
-
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; }
91
91
-
.btn:hover { opacity: 0.85; }
92
92
-
.btn-primary { background: var(--brand-color); color: #fff; }
99
99
+
.sidebar nav a {
100
100
+
display: block;
101
101
+
padding: 8px 20px;
102
102
+
font-size: 0.8125rem;
103
103
+
color: var(--secondary-color);
104
104
+
text-decoration: none;
105
105
+
transition: background 0.1s, color 0.1s;
106
106
+
}
93
107
94
94
-
.table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; }
95
95
-
table { width: 100%; border-collapse: collapse; }
96
96
-
thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); }
97
97
-
tbody tr { border-bottom: 1px solid var(--border-color); }
98
98
-
tbody tr:last-child { border-bottom: none; }
99
99
-
tbody tr:nth-child(even) { background: var(--table-stripe); }
100
100
-
tbody td { padding: 10px 16px; font-size: 0.8125rem; }
101
101
-
tbody td a { color: var(--brand-color); text-decoration: none; }
102
102
-
tbody td a:hover { text-decoration: underline; }
103
103
-
.empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; }
104
104
-
.did-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; color: var(--secondary-color); }
108
108
+
.sidebar nav a:hover {
109
109
+
background: var(--bg-secondary-color);
110
110
+
color: var(--primary-color);
111
111
+
}
112
112
+
113
113
+
.sidebar nav a.active {
114
114
+
color: var(--brand-color);
115
115
+
font-weight: 500;
116
116
+
}
117
117
+
118
118
+
.sidebar-footer {
119
119
+
padding: 16px 20px 0;
120
120
+
border-top: 1px solid var(--border-color);
121
121
+
margin-top: 16px;
122
122
+
}
123
123
+
124
124
+
.sidebar-footer .session-info {
125
125
+
font-size: 0.75rem;
126
126
+
color: var(--secondary-color);
127
127
+
margin-bottom: 8px;
128
128
+
}
129
129
+
130
130
+
.sidebar-footer form {
131
131
+
display: inline;
132
132
+
}
133
133
+
134
134
+
.sidebar-footer button {
135
135
+
background: none;
136
136
+
border: none;
137
137
+
font-size: 0.75rem;
138
138
+
color: var(--secondary-color);
139
139
+
cursor: pointer;
140
140
+
padding: 0;
141
141
+
text-decoration: underline;
142
142
+
}
143
143
+
144
144
+
.sidebar-footer button:hover {
145
145
+
color: var(--primary-color);
146
146
+
}
147
147
+
148
148
+
.main {
149
149
+
margin-left: 220px;
150
150
+
flex: 1;
151
151
+
padding: 32px;
152
152
+
max-width: 960px;
153
153
+
}
154
154
+
155
155
+
.page-title {
156
156
+
font-size: 1.5rem;
157
157
+
font-weight: 700;
158
158
+
margin-bottom: 24px;
159
159
+
}
160
160
+
161
161
+
.flash-success {
162
162
+
background: rgba(22, 163, 74, 0.1);
163
163
+
color: var(--success-color);
164
164
+
border: 1px solid rgba(22, 163, 74, 0.2);
165
165
+
border-radius: 8px;
166
166
+
padding: 10px 14px;
167
167
+
font-size: 0.875rem;
168
168
+
margin-bottom: 20px;
169
169
+
}
170
170
+
171
171
+
.flash-error {
172
172
+
background: rgba(220, 38, 38, 0.1);
173
173
+
color: var(--danger-color);
174
174
+
border: 1px solid rgba(220, 38, 38, 0.2);
175
175
+
border-radius: 8px;
176
176
+
padding: 10px 14px;
177
177
+
font-size: 0.875rem;
178
178
+
margin-bottom: 20px;
179
179
+
}
180
180
+
181
181
+
.search-form {
182
182
+
display: flex;
183
183
+
gap: 8px;
184
184
+
margin-bottom: 24px;
185
185
+
}
186
186
+
187
187
+
.search-form input {
188
188
+
flex: 1;
189
189
+
padding: 10px 12px;
190
190
+
font-size: 0.875rem;
191
191
+
border: 1px solid var(--border-color);
192
192
+
border-radius: 8px;
193
193
+
background: var(--bg-primary-color);
194
194
+
color: var(--primary-color);
195
195
+
outline: none;
196
196
+
}
197
197
+
198
198
+
.search-form input:focus {
199
199
+
border-color: var(--brand-color);
200
200
+
}
201
201
+
202
202
+
.btn {
203
203
+
display: inline-flex;
204
204
+
align-items: center;
205
205
+
justify-content: center;
206
206
+
padding: 10px 20px;
207
207
+
font-size: 0.875rem;
208
208
+
font-weight: 500;
209
209
+
border: none;
210
210
+
border-radius: 8px;
211
211
+
cursor: pointer;
212
212
+
transition: opacity 0.15s;
213
213
+
text-decoration: none;
214
214
+
}
215
215
+
216
216
+
.btn:hover {
217
217
+
opacity: 0.85;
218
218
+
}
219
219
+
220
220
+
.btn-primary {
221
221
+
background: var(--brand-color);
222
222
+
color: #fff;
223
223
+
}
224
224
+
225
225
+
.table-container {
226
226
+
background: var(--bg-primary-color);
227
227
+
border: 1px solid var(--border-color);
228
228
+
border-radius: 10px;
229
229
+
overflow: hidden;
230
230
+
}
231
231
+
232
232
+
table {
233
233
+
width: 100%;
234
234
+
border-collapse: collapse;
235
235
+
}
236
236
+
237
237
+
thead th {
238
238
+
text-align: left;
239
239
+
padding: 12px 16px;
240
240
+
font-size: 0.75rem;
241
241
+
font-weight: 600;
242
242
+
color: var(--secondary-color);
243
243
+
text-transform: uppercase;
244
244
+
letter-spacing: 0.5px;
245
245
+
border-bottom: 1px solid var(--border-color);
246
246
+
}
247
247
+
248
248
+
tbody tr {
249
249
+
border-bottom: 1px solid var(--border-color);
250
250
+
}
251
251
+
252
252
+
tbody tr:last-child {
253
253
+
border-bottom: none;
254
254
+
}
255
255
+
256
256
+
tbody tr:nth-child(even) {
257
257
+
background: var(--table-stripe);
258
258
+
}
259
259
+
260
260
+
tbody td {
261
261
+
padding: 10px 16px;
262
262
+
font-size: 0.8125rem;
263
263
+
}
264
264
+
265
265
+
tbody td a {
266
266
+
color: var(--brand-color);
267
267
+
text-decoration: none;
268
268
+
}
269
269
+
270
270
+
tbody td a:hover {
271
271
+
text-decoration: underline;
272
272
+
}
273
273
+
274
274
+
.empty-state {
275
275
+
text-align: center;
276
276
+
padding: 40px 20px;
277
277
+
color: var(--secondary-color);
278
278
+
font-size: 0.875rem;
279
279
+
}
280
280
+
281
281
+
.did-cell {
282
282
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
283
283
+
font-size: 0.75rem;
284
284
+
color: var(--secondary-color);
285
285
+
}
105
286
106
287
@media (max-width: 768px) {
107
107
-
.sidebar { display: none; }
108
108
-
.main { margin-left: 0; }
288
288
+
.sidebar {
289
289
+
display: none;
290
290
+
}
291
291
+
292
292
+
.main {
293
293
+
margin-left: 0;
294
294
+
}
109
295
}
110
296
</style>
111
297
</head>
112
298
<body>
113
113
-
<div class="layout">
114
114
-
<aside class="sidebar">
115
115
-
<div class="sidebar-title">{{pds_hostname}}</div>
116
116
-
<div class="sidebar-subtitle">Admin Portal</div>
117
117
-
<nav>
118
118
-
<a href="/admin/">Dashboard</a>
119
119
-
{{#if can_view_accounts}}
299
299
+
<div class="layout">
300
300
+
<aside class="sidebar">
301
301
+
<div class="sidebar-title">{{pds_hostname}}</div>
302
302
+
<div class="sidebar-subtitle">Admin Portal</div>
303
303
+
<nav>
304
304
+
<a href="/admin/dashboard">Dashboard</a>
305
305
+
{{#if can_view_accounts}}
120
306
<a href="/admin/accounts" class="active">Accounts</a>
121
121
-
{{/if}}
122
122
-
{{#if can_manage_invites}}
307
307
+
{{/if}}
308
308
+
{{#if can_manage_invites}}
123
309
<a href="/admin/invite-codes">Invite Codes</a>
124
124
-
{{/if}}
125
125
-
{{#if can_create_account}}
310
310
+
{{/if}}
311
311
+
{{#if can_create_account}}
126
312
<a href="/admin/create-account">Create Account</a>
127
127
-
{{/if}}
128
128
-
{{#if can_request_crawl}}
313
313
+
{{/if}}
314
314
+
{{#if can_request_crawl}}
129
315
<a href="/admin/request-crawl">Request Crawl</a>
130
130
-
{{/if}}
131
131
-
</nav>
132
132
-
<div class="sidebar-footer">
133
133
-
<div class="session-info">Signed in as {{handle}}</div>
134
134
-
<form method="POST" action="/admin/logout">
135
135
-
<button type="submit">Sign out</button>
136
136
-
</form>
137
137
-
</div>
138
138
-
</aside>
316
316
+
{{/if}}
317
317
+
</nav>
318
318
+
<div class="sidebar-footer">
319
319
+
<div class="session-info">Signed in as {{handle}}</div>
320
320
+
<form method="POST" action="/admin/logout">
321
321
+
<button type="submit">Sign out</button>
322
322
+
</form>
323
323
+
</div>
324
324
+
</aside>
139
325
140
140
-
<main class="main">
141
141
-
{{#if flash_success}}
326
326
+
<main class="main">
327
327
+
{{#if flash_success}}
142
328
<div class="flash-success">{{flash_success}}</div>
143
143
-
{{/if}}
144
144
-
{{#if flash_error}}
329
329
+
{{/if}}
330
330
+
{{#if flash_error}}
145
331
<div class="flash-error">{{flash_error}}</div>
146
146
-
{{/if}}
332
332
+
{{/if}}
147
333
148
148
-
<h1 class="page-title">Accounts</h1>
334
334
+
<h1 class="page-title">Accounts</h1>
149
335
150
150
-
<form class="search-form" method="GET" action="/admin/search">
151
151
-
<input type="text" name="q" placeholder="Search by email..." value="{{search_query}}" />
152
152
-
<button type="submit" class="btn btn-primary">Search</button>
153
153
-
</form>
336
336
+
<form class="search-form" method="GET" action="/admin/search">
337
337
+
<input type="text" name="q" placeholder="Search by email..." value="{{search_query}}"/>
338
338
+
<button type="submit" class="btn btn-primary">Search</button>
339
339
+
</form>
154
340
155
155
-
{{#if accounts}}
341
341
+
{{#if accounts}}
156
342
<div class="table-container">
157
343
<table>
158
344
<thead>
159
159
-
<tr>
160
160
-
<th>Handle</th>
161
161
-
<th>DID</th>
162
162
-
<th>Email</th>
163
163
-
</tr>
345
345
+
<tr>
346
346
+
<th>Handle</th>
347
347
+
<th>DID</th>
348
348
+
<th>Email</th>
349
349
+
</tr>
164
350
</thead>
165
351
<tbody>
166
166
-
{{#each accounts}}
352
352
+
{{#each accounts}}
167
353
<tr>
168
354
<td><a href="/admin/accounts/{{this.did}}">{{this.handle}}</a></td>
169
355
<td class="did-cell">{{this.did}}</td>
170
356
<td>{{this.email}}</td>
171
357
</tr>
172
172
-
{{/each}}
358
358
+
{{/each}}
173
359
</tbody>
174
360
</table>
175
361
</div>
176
176
-
{{else}}
362
362
+
{{else}}
177
363
<div class="empty-state">
178
364
{{#if search_query}}
179
179
-
No accounts matching "{{search_query}}"
365
365
+
No accounts matching "{{search_query}}"
180
366
{{else}}
181
181
-
No accounts found
367
367
+
No accounts found
182
368
{{/if}}
183
369
</div>
184
184
-
{{/if}}
185
185
-
</main>
186
186
-
</div>
370
370
+
{{/if}}
371
371
+
</main>
372
372
+
</div>
187
373
</body>
188
374
</html>
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
52
56
-webkit-font-smoothing: antialiased;
53
57
}
54
58
55
55
-
.layout { display: flex; min-height: 100vh; }
59
59
+
.layout {
60
60
+
display: flex;
61
61
+
min-height: 100vh;
62
62
+
}
63
63
+
64
64
+
.sidebar {
65
65
+
width: 220px;
66
66
+
background: var(--bg-primary-color);
67
67
+
border-right: 1px solid var(--border-color);
68
68
+
padding: 20px 0;
69
69
+
position: fixed;
70
70
+
top: 0;
71
71
+
left: 0;
72
72
+
bottom: 0;
73
73
+
overflow-y: auto;
74
74
+
display: flex;
75
75
+
flex-direction: column;
76
76
+
}
77
77
+
78
78
+
.sidebar-title {
79
79
+
font-size: 0.8125rem;
80
80
+
font-weight: 700;
81
81
+
padding: 0 20px;
82
82
+
margin-bottom: 4px;
83
83
+
white-space: nowrap;
84
84
+
overflow: hidden;
85
85
+
text-overflow: ellipsis;
86
86
+
}
87
87
+
88
88
+
.sidebar-subtitle {
89
89
+
font-size: 0.6875rem;
90
90
+
color: var(--secondary-color);
91
91
+
padding: 0 20px;
92
92
+
margin-bottom: 20px;
93
93
+
}
94
94
+
95
95
+
.sidebar nav {
96
96
+
flex: 1;
97
97
+
}
98
98
+
99
99
+
.sidebar nav a {
100
100
+
display: block;
101
101
+
padding: 8px 20px;
102
102
+
font-size: 0.8125rem;
103
103
+
color: var(--secondary-color);
104
104
+
text-decoration: none;
105
105
+
transition: background 0.1s, color 0.1s;
106
106
+
}
107
107
+
108
108
+
.sidebar nav a:hover {
109
109
+
background: var(--bg-secondary-color);
110
110
+
color: var(--primary-color);
111
111
+
}
112
112
+
113
113
+
.sidebar nav a.active {
114
114
+
color: var(--brand-color);
115
115
+
font-weight: 500;
116
116
+
}
56
117
57
57
-
.sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; }
58
58
-
.sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
59
59
-
.sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; }
60
60
-
.sidebar nav { flex: 1; }
61
61
-
.sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; }
62
62
-
.sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); }
63
63
-
.sidebar nav a.active { color: var(--brand-color); font-weight: 500; }
64
64
-
.sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; }
65
65
-
.sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; }
66
66
-
.sidebar-footer form { display: inline; }
67
67
-
.sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; }
68
68
-
.sidebar-footer button:hover { color: var(--primary-color); }
118
118
+
.sidebar-footer {
119
119
+
padding: 16px 20px 0;
120
120
+
border-top: 1px solid var(--border-color);
121
121
+
margin-top: 16px;
122
122
+
}
123
123
+
124
124
+
.sidebar-footer .session-info {
125
125
+
font-size: 0.75rem;
126
126
+
color: var(--secondary-color);
127
127
+
margin-bottom: 8px;
128
128
+
}
129
129
+
130
130
+
.sidebar-footer form {
131
131
+
display: inline;
132
132
+
}
133
133
+
134
134
+
.sidebar-footer button {
135
135
+
background: none;
136
136
+
border: none;
137
137
+
font-size: 0.75rem;
138
138
+
color: var(--secondary-color);
139
139
+
cursor: pointer;
140
140
+
padding: 0;
141
141
+
text-decoration: underline;
142
142
+
}
143
143
+
144
144
+
.sidebar-footer button:hover {
145
145
+
color: var(--primary-color);
146
146
+
}
147
147
+
148
148
+
.main {
149
149
+
margin-left: 220px;
150
150
+
flex: 1;
151
151
+
padding: 32px;
152
152
+
max-width: 960px;
153
153
+
}
154
154
+
155
155
+
.page-title {
156
156
+
font-size: 1.5rem;
157
157
+
font-weight: 700;
158
158
+
margin-bottom: 24px;
159
159
+
}
160
160
+
161
161
+
.flash-success {
162
162
+
background: rgba(22, 163, 74, 0.1);
163
163
+
color: var(--success-color);
164
164
+
border: 1px solid rgba(22, 163, 74, 0.2);
165
165
+
border-radius: 8px;
166
166
+
padding: 10px 14px;
167
167
+
font-size: 0.875rem;
168
168
+
margin-bottom: 20px;
169
169
+
}
170
170
+
171
171
+
.flash-error {
172
172
+
background: rgba(220, 38, 38, 0.1);
173
173
+
color: var(--danger-color);
174
174
+
border: 1px solid rgba(220, 38, 38, 0.2);
175
175
+
border-radius: 8px;
176
176
+
padding: 10px 14px;
177
177
+
font-size: 0.875rem;
178
178
+
margin-bottom: 20px;
179
179
+
}
180
180
+
181
181
+
.form-card {
182
182
+
background: var(--bg-primary-color);
183
183
+
border: 1px solid var(--border-color);
184
184
+
border-radius: 10px;
185
185
+
padding: 24px;
186
186
+
max-width: 480px;
187
187
+
}
188
188
+
189
189
+
.form-group {
190
190
+
margin-bottom: 16px;
191
191
+
}
192
192
+
193
193
+
.form-group label {
194
194
+
display: block;
195
195
+
font-size: 0.8125rem;
196
196
+
font-weight: 500;
197
197
+
margin-bottom: 6px;
198
198
+
color: var(--primary-color);
199
199
+
}
200
200
+
201
201
+
.form-group input {
202
202
+
width: 100%;
203
203
+
padding: 10px 12px;
204
204
+
font-size: 0.875rem;
205
205
+
border: 1px solid var(--border-color);
206
206
+
border-radius: 8px;
207
207
+
background: var(--bg-primary-color);
208
208
+
color: var(--primary-color);
209
209
+
outline: none;
210
210
+
transition: border-color 0.15s;
211
211
+
}
212
212
+
213
213
+
.form-group input:focus {
214
214
+
border-color: var(--brand-color);
215
215
+
}
216
216
+
217
217
+
.form-group .hint {
218
218
+
font-size: 0.75rem;
219
219
+
color: var(--secondary-color);
220
220
+
margin-top: 4px;
221
221
+
}
222
222
+
223
223
+
.btn {
224
224
+
display: inline-flex;
225
225
+
align-items: center;
226
226
+
justify-content: center;
227
227
+
padding: 10px 20px;
228
228
+
font-size: 0.875rem;
229
229
+
font-weight: 500;
230
230
+
border: none;
231
231
+
border-radius: 8px;
232
232
+
cursor: pointer;
233
233
+
transition: opacity 0.15s;
234
234
+
text-decoration: none;
235
235
+
}
236
236
+
237
237
+
.btn:hover {
238
238
+
opacity: 0.85;
239
239
+
}
240
240
+
241
241
+
.btn-primary {
242
242
+
background: var(--brand-color);
243
243
+
color: #fff;
244
244
+
}
245
245
+
246
246
+
.success-card {
247
247
+
background: var(--bg-primary-color);
248
248
+
border: 1px solid var(--border-color);
249
249
+
border-radius: 10px;
250
250
+
padding: 24px;
251
251
+
max-width: 480px;
252
252
+
}
69
253
70
70
-
.main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; }
71
71
-
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; }
254
254
+
.success-card h3 {
255
255
+
font-size: 1rem;
256
256
+
font-weight: 600;
257
257
+
color: var(--success-color);
258
258
+
margin-bottom: 16px;
259
259
+
}
72
260
73
73
-
.flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
74
74
-
.flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
261
261
+
.detail-row {
262
262
+
display: flex;
263
263
+
justify-content: space-between;
264
264
+
align-items: center;
265
265
+
padding: 8px 0;
266
266
+
font-size: 0.8125rem;
267
267
+
border-bottom: 1px solid var(--border-color);
268
268
+
}
75
269
76
76
-
.form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; }
77
77
-
.form-group { margin-bottom: 16px; }
78
78
-
.form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); }
79
79
-
.form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; }
80
80
-
.form-group input:focus { border-color: var(--brand-color); }
81
81
-
.form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; }
270
270
+
.detail-row:last-child {
271
271
+
border-bottom: none;
272
272
+
}
82
273
83
83
-
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; }
84
84
-
.btn:hover { opacity: 0.85; }
85
85
-
.btn-primary { background: var(--brand-color); color: #fff; }
274
274
+
.detail-row .label {
275
275
+
color: var(--secondary-color);
276
276
+
}
86
277
87
87
-
.success-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; }
88
88
-
.success-card h3 { font-size: 1rem; font-weight: 600; color: var(--success-color); margin-bottom: 16px; }
278
278
+
.detail-row .value {
279
279
+
font-weight: 500;
280
280
+
word-break: break-all;
281
281
+
text-align: right;
282
282
+
max-width: 65%;
283
283
+
display: flex;
284
284
+
align-items: center;
285
285
+
gap: 6px;
286
286
+
}
89
287
90
90
-
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); }
91
91
-
.detail-row:last-child { border-bottom: none; }
92
92
-
.detail-row .label { color: var(--secondary-color); }
93
93
-
.detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; display: flex; align-items: center; gap: 6px; }
288
288
+
.password-highlight {
289
289
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
290
290
+
background: rgba(22, 163, 74, 0.08);
291
291
+
padding: 2px 6px;
292
292
+
border-radius: 4px;
293
293
+
user-select: all;
294
294
+
}
94
295
95
95
-
.password-highlight { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; background: rgba(22, 163, 74, 0.08); padding: 2px 6px; border-radius: 4px; user-select: all; }
296
296
+
.copy-btn {
297
297
+
background: none;
298
298
+
border: 1px solid var(--border-color);
299
299
+
border-radius: 4px;
300
300
+
padding: 2px 6px;
301
301
+
font-size: 0.6875rem;
302
302
+
cursor: pointer;
303
303
+
color: var(--secondary-color);
304
304
+
transition: color 0.15s, border-color 0.15s;
305
305
+
white-space: nowrap;
306
306
+
}
96
307
97
97
-
.copy-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 2px 6px; font-size: 0.6875rem; cursor: pointer; color: var(--secondary-color); transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
98
98
-
.copy-btn:hover { color: var(--primary-color); border-color: var(--primary-color); }
308
308
+
.copy-btn:hover {
309
309
+
color: var(--primary-color);
310
310
+
border-color: var(--primary-color);
311
311
+
}
99
312
100
313
@media (max-width: 768px) {
101
101
-
.sidebar { display: none; }
102
102
-
.main { margin-left: 0; }
314
314
+
.sidebar {
315
315
+
display: none;
316
316
+
}
317
317
+
318
318
+
.main {
319
319
+
margin-left: 0;
320
320
+
}
103
321
}
104
322
</style>
105
323
</head>
106
324
<body>
107
107
-
<div class="layout">
108
108
-
<aside class="sidebar">
109
109
-
<div class="sidebar-title">{{pds_hostname}}</div>
110
110
-
<div class="sidebar-subtitle">Admin Portal</div>
111
111
-
<nav>
112
112
-
<a href="/admin/">Dashboard</a>
113
113
-
{{#if can_view_accounts}}
325
325
+
<div class="layout">
326
326
+
<aside class="sidebar">
327
327
+
<div class="sidebar-title">{{pds_hostname}}</div>
328
328
+
<div class="sidebar-subtitle">Admin Portal</div>
329
329
+
<nav>
330
330
+
<a href="/admin/dashboard">Dashboard</a>
331
331
+
{{#if can_view_accounts}}
114
332
<a href="/admin/accounts">Accounts</a>
115
115
-
{{/if}}
116
116
-
{{#if can_manage_invites}}
333
333
+
{{/if}}
334
334
+
{{#if can_manage_invites}}
117
335
<a href="/admin/invite-codes">Invite Codes</a>
118
118
-
{{/if}}
119
119
-
{{#if can_create_account}}
336
336
+
{{/if}}
337
337
+
{{#if can_create_account}}
120
338
<a href="/admin/create-account" class="active">Create Account</a>
121
121
-
{{/if}}
122
122
-
{{#if can_request_crawl}}
339
339
+
{{/if}}
340
340
+
{{#if can_request_crawl}}
123
341
<a href="/admin/request-crawl">Request Crawl</a>
124
124
-
{{/if}}
125
125
-
</nav>
126
126
-
<div class="sidebar-footer">
127
127
-
<div class="session-info">Signed in as {{handle}}</div>
128
128
-
<form method="POST" action="/admin/logout">
129
129
-
<button type="submit">Sign out</button>
130
130
-
</form>
131
131
-
</div>
132
132
-
</aside>
342
342
+
{{/if}}
343
343
+
</nav>
344
344
+
<div class="sidebar-footer">
345
345
+
<div class="session-info">Signed in as {{handle}}</div>
346
346
+
<form method="POST" action="/admin/logout">
347
347
+
<button type="submit">Sign out</button>
348
348
+
</form>
349
349
+
</div>
350
350
+
</aside>
133
351
134
134
-
<main class="main">
135
135
-
{{#if flash_success}}
352
352
+
<main class="main">
353
353
+
{{#if flash_success}}
136
354
<div class="flash-success">{{flash_success}}</div>
137
137
-
{{/if}}
138
138
-
{{#if flash_error}}
355
355
+
{{/if}}
356
356
+
{{#if flash_error}}
139
357
<div class="flash-error">{{flash_error}}</div>
140
140
-
{{/if}}
358
358
+
{{/if}}
141
359
142
142
-
<h1 class="page-title">Create Account</h1>
360
360
+
<h1 class="page-title">Create Account</h1>
143
361
144
144
-
{{#if created}}
362
362
+
{{#if created}}
145
363
<div class="success-card">
146
364
<h3>Account Created Successfully</h3>
147
365
<div class="detail-row">
148
366
<span class="label">DID</span>
149
149
-
<span class="value">{{created.did}} <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span>
367
367
+
<span class="value">{{created.did}}
368
368
+
<button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span>
150
369
</div>
151
370
<div class="detail-row">
152
371
<span class="label">Handle</span>
153
153
-
<span class="value">{{created.handle}} <button class="copy-btn" onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span>
372
372
+
<span class="value">{{created.handle}}
373
373
+
<button class="copy-btn"
374
374
+
onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span>
154
375
</div>
155
376
<div class="detail-row">
156
377
<span class="label">Email</span>
157
157
-
<span class="value">{{created.email}} <button class="copy-btn" onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span>
378
378
+
<span class="value">{{created.email}}
379
379
+
<button class="copy-btn"
380
380
+
onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span>
158
381
</div>
159
382
<div class="detail-row">
160
383
<span class="label">Password</span>
161
161
-
<span class="value"><span class="password-highlight">{{created.password}}</span> <button class="copy-btn" onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span>
384
384
+
<span class="value"><span class="password-highlight">{{created.password}}</span> <button
385
385
+
class="copy-btn"
386
386
+
onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span>
162
387
</div>
163
388
{{#if created.inviteCode}}
164
164
-
<div class="detail-row">
165
165
-
<span class="label">Invite Code Used</span>
166
166
-
<span class="value">{{created.inviteCode}} <button class="copy-btn" onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span>
167
167
-
</div>
389
389
+
<div class="detail-row">
390
390
+
<span class="label">Invite Code Used</span>
391
391
+
<span class="value">{{created.inviteCode}}
392
392
+
<button class="copy-btn"
393
393
+
onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span>
394
394
+
</div>
168
395
{{/if}}
169
396
</div>
170
170
-
{{else}}
397
397
+
{{else}}
171
398
<div class="form-card">
172
399
<form method="POST" action="/admin/create-account">
173
400
<div class="form-group">
174
401
<label for="email">Email</label>
175
175
-
<input type="email" id="email" name="email" placeholder="user@example.com" required />
402
402
+
<input type="email" id="email" name="email" placeholder="user@example.com" required/>
176
403
</div>
177
404
<div class="form-group">
178
405
<label for="handle">Handle</label>
179
179
-
<input type="text" id="handle" name="handle" placeholder="user.example.com" required />
406
406
+
<input type="text" id="handle" name="handle" placeholder="user.example.com" required/>
180
407
<div class="hint">Must be a valid handle for this PDS</div>
181
408
</div>
182
409
<button type="submit" class="btn btn-primary">Create Account</button>
183
410
</form>
184
411
</div>
185
185
-
{{/if}}
186
186
-
</main>
187
187
-
</div>
412
412
+
{{/if}}
413
413
+
</main>
414
414
+
</div>
188
415
189
189
-
<script>
416
416
+
<script>
190
417
function copyToClipboard(text, btn) {
191
418
if (navigator.clipboard && navigator.clipboard.writeText) {
192
192
-
navigator.clipboard.writeText(text).then(function() {
419
419
+
navigator.clipboard.writeText(text).then(function () {
193
420
var orig = btn.textContent;
194
421
btn.textContent = 'Copied';
195
195
-
setTimeout(function() { btn.textContent = orig; }, 1500);
422
422
+
setTimeout(function () {
423
423
+
btn.textContent = orig;
424
424
+
}, 1500);
196
425
});
197
426
} else {
198
427
var el = document.createElement('textarea');
···
205
434
document.body.removeChild(el);
206
435
var orig = btn.textContent;
207
436
btn.textContent = 'Copied';
208
208
-
setTimeout(function() { btn.textContent = orig; }, 1500);
437
437
+
setTimeout(function () {
438
438
+
btn.textContent = orig;
439
439
+
}, 1500);
209
440
}
210
441
}
211
211
-
</script>
442
442
+
</script>
212
443
</body>
213
444
</html>
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
202
206
font-weight: 600;
203
207
}
204
208
205
205
-
.card-value.success { color: var(--success-color); }
206
206
-
.card-value.danger { color: var(--danger-color); }
209
209
+
.card-value.success {
210
210
+
color: var(--success-color);
211
211
+
}
212
212
+
213
213
+
.card-value.danger {
214
214
+
color: var(--danger-color);
215
215
+
}
207
216
208
217
.detail-section {
209
218
background: var(--bg-primary-color);
···
255
264
.sidebar {
256
265
display: none;
257
266
}
267
267
+
258
268
.main {
259
269
margin-left: 0;
260
270
}
···
262
272
</style>
263
273
</head>
264
274
<body>
265
265
-
<div class="layout">
266
266
-
<aside class="sidebar">
267
267
-
<div class="sidebar-title">{{pds_hostname}}</div>
268
268
-
<div class="sidebar-subtitle">Admin Portal</div>
269
269
-
<nav>
270
270
-
<a href="/admin/" class="active">Dashboard</a>
271
271
-
{{#if can_view_accounts}}
275
275
+
<div class="layout">
276
276
+
<aside class="sidebar">
277
277
+
<div class="sidebar-title">{{pds_hostname}}</div>
278
278
+
<div class="sidebar-subtitle">Admin Portal</div>
279
279
+
<nav>
280
280
+
<a href="/admin/dashboard" class="active">Dashboard</a>
281
281
+
{{#if can_view_accounts}}
272
282
<a href="/admin/accounts">Accounts</a>
273
273
-
{{/if}}
274
274
-
{{#if can_manage_invites}}
283
283
+
{{/if}}
284
284
+
{{#if can_manage_invites}}
275
285
<a href="/admin/invite-codes">Invite Codes</a>
276
276
-
{{/if}}
277
277
-
{{#if can_create_account}}
286
286
+
{{/if}}
287
287
+
{{#if can_create_account}}
278
288
<a href="/admin/create-account">Create Account</a>
279
279
-
{{/if}}
280
280
-
{{#if can_request_crawl}}
289
289
+
{{/if}}
290
290
+
{{#if can_request_crawl}}
281
291
<a href="/admin/request-crawl">Request Crawl</a>
282
282
-
{{/if}}
283
283
-
</nav>
284
284
-
<div class="sidebar-footer">
285
285
-
<div class="session-info">Signed in as {{handle}}</div>
286
286
-
<form method="POST" action="/admin/logout">
287
287
-
<button type="submit">Sign out</button>
288
288
-
</form>
289
289
-
</div>
290
290
-
</aside>
292
292
+
{{/if}}
293
293
+
</nav>
294
294
+
<div class="sidebar-footer">
295
295
+
<div class="session-info">Signed in as {{handle}}</div>
296
296
+
<form method="POST" action="/admin/logout">
297
297
+
<button type="submit">Sign out</button>
298
298
+
</form>
299
299
+
</div>
300
300
+
</aside>
291
301
292
292
-
<main class="main">
293
293
-
{{#if flash_success}}
302
302
+
<main class="main">
303
303
+
{{#if flash_success}}
294
304
<div class="flash-success">{{flash_success}}</div>
295
295
-
{{/if}}
296
296
-
{{#if flash_error}}
305
305
+
{{/if}}
306
306
+
{{#if flash_error}}
297
307
<div class="flash-error">{{flash_error}}</div>
298
298
-
{{/if}}
308
308
+
{{/if}}
299
309
300
300
-
<h1 class="page-title">Dashboard</h1>
310
310
+
<h1 class="page-title">Dashboard</h1>
301
311
302
302
-
<div class="cards">
303
303
-
<div class="card">
304
304
-
<div class="card-label">PDS Version</div>
305
305
-
<div class="card-value">{{version}}</div>
306
306
-
</div>
307
307
-
{{#if can_view_accounts}}
312
312
+
<div class="cards">
313
313
+
<div class="card">
314
314
+
<div class="card-label">PDS Version</div>
315
315
+
<div class="card-value">{{version}}</div>
316
316
+
</div>
317
317
+
{{#if can_view_accounts}}
308
318
<div class="card">
309
319
<div class="card-label">Total Accounts</div>
310
320
<div class="card-value">{{account_count}}</div>
311
321
</div>
312
312
-
{{/if}}
313
313
-
<div class="card">
314
314
-
<div class="card-label">Invite Code Required</div>
315
315
-
<div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div>
316
316
-
</div>
317
317
-
<div class="card">
318
318
-
<div class="card-label">Phone Verification</div>
319
319
-
<div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div>
320
320
-
</div>
322
322
+
{{/if}}
323
323
+
<div class="card">
324
324
+
<div class="card-label">Invite Code Required</div>
325
325
+
<div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div>
326
326
+
</div>
327
327
+
<div class="card">
328
328
+
<div class="card-label">Phone Verification</div>
329
329
+
<div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div>
321
330
</div>
331
331
+
</div>
322
332
323
323
-
<div class="detail-section">
324
324
-
<h3>Server Information</h3>
325
325
-
<div class="detail-row">
326
326
-
<span class="label">Server DID</span>
327
327
-
<span class="value">{{server_did}}</span>
328
328
-
</div>
329
329
-
<div class="detail-row">
330
330
-
<span class="label">Available Domains</span>
331
331
-
<span class="value">{{available_domains}}</span>
332
332
-
</div>
333
333
-
{{#if contact_email}}
333
333
+
<div class="detail-section">
334
334
+
<h3>Server Information</h3>
335
335
+
<div class="detail-row">
336
336
+
<span class="label">Server DID</span>
337
337
+
<span class="value">{{server_did}}</span>
338
338
+
</div>
339
339
+
<div class="detail-row">
340
340
+
<span class="label">Available Domains</span>
341
341
+
<span class="value">{{available_domains}}</span>
342
342
+
</div>
343
343
+
{{#if contact_email}}
334
344
<div class="detail-row">
335
345
<span class="label">Contact Email</span>
336
346
<span class="value">{{contact_email}}</span>
337
347
</div>
338
338
-
{{/if}}
339
339
-
</div>
348
348
+
{{/if}}
349
349
+
</div>
340
350
341
341
-
{{#if privacy_policy}}
351
351
+
{{#if privacy_policy}}
342
352
<div class="detail-section">
343
353
<h3>Server Links</h3>
344
354
{{#if terms_of_service}}
345
345
-
<div class="detail-row">
346
346
-
<span class="label">Terms of Service</span>
347
347
-
<span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span>
348
348
-
</div>
355
355
+
<div class="detail-row">
356
356
+
<span class="label">Terms of Service</span>
357
357
+
<span class="value"><a href="{{terms_of_service}}" target="_blank"
358
358
+
rel="noopener">{{terms_of_service}}</a></span>
359
359
+
</div>
349
360
{{/if}}
350
361
{{#if privacy_policy}}
351
351
-
<div class="detail-row">
352
352
-
<span class="label">Privacy Policy</span>
353
353
-
<span class="value"><a href="{{privacy_policy}}" target="_blank" rel="noopener">{{privacy_policy}}</a></span>
354
354
-
</div>
362
362
+
<div class="detail-row">
363
363
+
<span class="label">Privacy Policy</span>
364
364
+
<span class="value"><a href="{{privacy_policy}}" target="_blank"
365
365
+
rel="noopener">{{privacy_policy}}</a></span>
366
366
+
</div>
355
367
{{/if}}
356
368
</div>
357
357
-
{{else}}
369
369
+
{{else}}
358
370
{{#if terms_of_service}}
359
359
-
<div class="detail-section">
360
360
-
<h3>Server Links</h3>
361
361
-
<div class="detail-row">
362
362
-
<span class="label">Terms of Service</span>
363
363
-
<span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span>
371
371
+
<div class="detail-section">
372
372
+
<h3>Server Links</h3>
373
373
+
<div class="detail-row">
374
374
+
<span class="label">Terms of Service</span>
375
375
+
<span class="value"><a href="{{terms_of_service}}" target="_blank"
376
376
+
rel="noopener">{{terms_of_service}}</a></span>
377
377
+
</div>
364
378
</div>
365
365
-
</div>
366
366
-
{{/if}}
367
379
{{/if}}
368
368
-
</main>
369
369
-
</div>
380
380
+
{{/if}}
381
381
+
</main>
382
382
+
</div>
370
383
</body>
371
384
</html>
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
52
56
-webkit-font-smoothing: antialiased;
53
57
}
54
58
55
55
-
.layout { display: flex; min-height: 100vh; }
59
59
+
.layout {
60
60
+
display: flex;
61
61
+
min-height: 100vh;
62
62
+
}
63
63
+
64
64
+
.sidebar {
65
65
+
width: 220px;
66
66
+
background: var(--bg-primary-color);
67
67
+
border-right: 1px solid var(--border-color);
68
68
+
padding: 20px 0;
69
69
+
position: fixed;
70
70
+
top: 0;
71
71
+
left: 0;
72
72
+
bottom: 0;
73
73
+
overflow-y: auto;
74
74
+
display: flex;
75
75
+
flex-direction: column;
76
76
+
}
77
77
+
78
78
+
.sidebar-title {
79
79
+
font-size: 0.8125rem;
80
80
+
font-weight: 700;
81
81
+
padding: 0 20px;
82
82
+
margin-bottom: 4px;
83
83
+
white-space: nowrap;
84
84
+
overflow: hidden;
85
85
+
text-overflow: ellipsis;
86
86
+
}
87
87
+
88
88
+
.sidebar-subtitle {
89
89
+
font-size: 0.6875rem;
90
90
+
color: var(--secondary-color);
91
91
+
padding: 0 20px;
92
92
+
margin-bottom: 20px;
93
93
+
}
94
94
+
95
95
+
.sidebar nav {
96
96
+
flex: 1;
97
97
+
}
98
98
+
99
99
+
.sidebar nav a {
100
100
+
display: block;
101
101
+
padding: 8px 20px;
102
102
+
font-size: 0.8125rem;
103
103
+
color: var(--secondary-color);
104
104
+
text-decoration: none;
105
105
+
transition: background 0.1s, color 0.1s;
106
106
+
}
107
107
+
108
108
+
.sidebar nav a:hover {
109
109
+
background: var(--bg-secondary-color);
110
110
+
color: var(--primary-color);
111
111
+
}
112
112
+
113
113
+
.sidebar nav a.active {
114
114
+
color: var(--brand-color);
115
115
+
font-weight: 500;
116
116
+
}
117
117
+
118
118
+
.sidebar-footer {
119
119
+
padding: 16px 20px 0;
120
120
+
border-top: 1px solid var(--border-color);
121
121
+
margin-top: 16px;
122
122
+
}
123
123
+
124
124
+
.sidebar-footer .session-info {
125
125
+
font-size: 0.75rem;
126
126
+
color: var(--secondary-color);
127
127
+
margin-bottom: 8px;
128
128
+
}
129
129
+
130
130
+
.sidebar-footer form {
131
131
+
display: inline;
132
132
+
}
133
133
+
134
134
+
.sidebar-footer button {
135
135
+
background: none;
136
136
+
border: none;
137
137
+
font-size: 0.75rem;
138
138
+
color: var(--secondary-color);
139
139
+
cursor: pointer;
140
140
+
padding: 0;
141
141
+
text-decoration: underline;
142
142
+
}
143
143
+
144
144
+
.sidebar-footer button:hover {
145
145
+
color: var(--primary-color);
146
146
+
}
147
147
+
148
148
+
.main {
149
149
+
margin-left: 220px;
150
150
+
flex: 1;
151
151
+
padding: 32px;
152
152
+
max-width: 960px;
153
153
+
}
154
154
+
155
155
+
.page-title {
156
156
+
font-size: 1.5rem;
157
157
+
font-weight: 700;
158
158
+
margin-bottom: 24px;
159
159
+
}
160
160
+
161
161
+
.flash-success {
162
162
+
background: rgba(22, 163, 74, 0.1);
163
163
+
color: var(--success-color);
164
164
+
border: 1px solid rgba(22, 163, 74, 0.2);
165
165
+
border-radius: 8px;
166
166
+
padding: 10px 14px;
167
167
+
font-size: 0.875rem;
168
168
+
margin-bottom: 20px;
169
169
+
}
170
170
+
171
171
+
.flash-error {
172
172
+
background: rgba(220, 38, 38, 0.1);
173
173
+
color: var(--danger-color);
174
174
+
border: 1px solid rgba(220, 38, 38, 0.2);
175
175
+
border-radius: 8px;
176
176
+
padding: 10px 14px;
177
177
+
font-size: 0.875rem;
178
178
+
margin-bottom: 20px;
179
179
+
}
180
180
+
181
181
+
.create-form {
182
182
+
display: flex;
183
183
+
gap: 8px;
184
184
+
align-items: flex-end;
185
185
+
margin-bottom: 24px;
186
186
+
}
187
187
+
188
188
+
.create-form .form-group {
189
189
+
display: flex;
190
190
+
flex-direction: column;
191
191
+
gap: 4px;
192
192
+
}
193
193
+
194
194
+
.create-form label {
195
195
+
font-size: 0.75rem;
196
196
+
font-weight: 500;
197
197
+
color: var(--secondary-color);
198
198
+
}
199
199
+
200
200
+
.create-form input[type="number"] {
201
201
+
padding: 10px 12px;
202
202
+
font-size: 0.875rem;
203
203
+
border: 1px solid var(--border-color);
204
204
+
border-radius: 8px;
205
205
+
background: var(--bg-primary-color);
206
206
+
color: var(--primary-color);
207
207
+
outline: none;
208
208
+
width: 120px;
209
209
+
}
210
210
+
211
211
+
.create-form input[type="number"]:focus {
212
212
+
border-color: var(--brand-color);
213
213
+
}
214
214
+
215
215
+
.btn {
216
216
+
display: inline-flex;
217
217
+
align-items: center;
218
218
+
justify-content: center;
219
219
+
padding: 10px 20px;
220
220
+
font-size: 0.875rem;
221
221
+
font-weight: 500;
222
222
+
border: none;
223
223
+
border-radius: 8px;
224
224
+
cursor: pointer;
225
225
+
transition: opacity 0.15s;
226
226
+
text-decoration: none;
227
227
+
}
228
228
+
229
229
+
.btn:hover {
230
230
+
opacity: 0.85;
231
231
+
}
232
232
+
233
233
+
.btn-primary {
234
234
+
background: var(--brand-color);
235
235
+
color: #fff;
236
236
+
}
237
237
+
238
238
+
.btn-small {
239
239
+
padding: 6px 12px;
240
240
+
font-size: 0.75rem;
241
241
+
}
242
242
+
243
243
+
.btn-outline-danger {
244
244
+
background: transparent;
245
245
+
color: var(--danger-color);
246
246
+
border: 1px solid var(--danger-color);
247
247
+
}
248
248
+
249
249
+
.code-box {
250
250
+
background: rgba(22, 163, 74, 0.08);
251
251
+
border: 1px solid rgba(22, 163, 74, 0.2);
252
252
+
border-radius: 10px;
253
253
+
padding: 16px 20px;
254
254
+
margin-bottom: 24px;
255
255
+
}
256
256
+
257
257
+
.code-box .code-label {
258
258
+
font-size: 0.75rem;
259
259
+
font-weight: 600;
260
260
+
color: var(--success-color);
261
261
+
margin-bottom: 6px;
262
262
+
}
263
263
+
264
264
+
.code-box .code-value {
265
265
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
266
266
+
font-size: 1rem;
267
267
+
font-weight: 600;
268
268
+
user-select: all;
269
269
+
}
270
270
+
271
271
+
.table-container {
272
272
+
background: var(--bg-primary-color);
273
273
+
border: 1px solid var(--border-color);
274
274
+
border-radius: 10px;
275
275
+
overflow: hidden;
276
276
+
}
277
277
+
278
278
+
table {
279
279
+
width: 100%;
280
280
+
border-collapse: collapse;
281
281
+
}
282
282
+
283
283
+
thead th {
284
284
+
text-align: left;
285
285
+
padding: 12px 16px;
286
286
+
font-size: 0.75rem;
287
287
+
font-weight: 600;
288
288
+
color: var(--secondary-color);
289
289
+
text-transform: uppercase;
290
290
+
letter-spacing: 0.5px;
291
291
+
border-bottom: 1px solid var(--border-color);
292
292
+
}
56
293
57
57
-
.sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; }
58
58
-
.sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
59
59
-
.sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; }
60
60
-
.sidebar nav { flex: 1; }
61
61
-
.sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; }
62
62
-
.sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); }
63
63
-
.sidebar nav a.active { color: var(--brand-color); font-weight: 500; }
64
64
-
.sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; }
65
65
-
.sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; }
66
66
-
.sidebar-footer form { display: inline; }
67
67
-
.sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; }
68
68
-
.sidebar-footer button:hover { color: var(--primary-color); }
294
294
+
tbody tr {
295
295
+
border-bottom: 1px solid var(--border-color);
296
296
+
}
69
297
70
70
-
.main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; }
71
71
-
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; }
298
298
+
tbody tr:last-child {
299
299
+
border-bottom: none;
300
300
+
}
72
301
73
73
-
.flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
74
74
-
.flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
302
302
+
tbody tr:nth-child(even) {
303
303
+
background: var(--table-stripe);
304
304
+
}
75
305
76
76
-
.create-form { display: flex; gap: 8px; align-items: flex-end; margin-bottom: 24px; }
77
77
-
.create-form .form-group { display: flex; flex-direction: column; gap: 4px; }
78
78
-
.create-form label { font-size: 0.75rem; font-weight: 500; color: var(--secondary-color); }
79
79
-
.create-form input[type="number"] { padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; width: 120px; }
80
80
-
.create-form input[type="number"]:focus { border-color: var(--brand-color); }
306
306
+
tbody td {
307
307
+
padding: 10px 16px;
308
308
+
font-size: 0.8125rem;
309
309
+
}
81
310
82
82
-
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; }
83
83
-
.btn:hover { opacity: 0.85; }
84
84
-
.btn-primary { background: var(--brand-color); color: #fff; }
85
85
-
.btn-small { padding: 6px 12px; font-size: 0.75rem; }
86
86
-
.btn-outline-danger { background: transparent; color: var(--danger-color); border: 1px solid var(--danger-color); }
311
311
+
.code-cell {
312
312
+
font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
313
313
+
font-size: 0.75rem;
314
314
+
}
87
315
88
88
-
.code-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; }
89
89
-
.code-box .code-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; }
90
90
-
.code-box .code-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; }
316
316
+
.badge {
317
317
+
display: inline-block;
318
318
+
padding: 2px 8px;
319
319
+
border-radius: 4px;
320
320
+
font-size: 0.75rem;
321
321
+
font-weight: 500;
322
322
+
}
91
323
92
92
-
.table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; }
93
93
-
table { width: 100%; border-collapse: collapse; }
94
94
-
thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); }
95
95
-
tbody tr { border-bottom: 1px solid var(--border-color); }
96
96
-
tbody tr:last-child { border-bottom: none; }
97
97
-
tbody tr:nth-child(even) { background: var(--table-stripe); }
98
98
-
tbody td { padding: 10px 16px; font-size: 0.8125rem; }
99
99
-
.code-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; }
324
324
+
.badge-success {
325
325
+
background: rgba(22, 163, 74, 0.1);
326
326
+
color: var(--success-color);
327
327
+
}
100
328
101
101
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
102
102
-
.badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); }
103
103
-
.badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); }
329
329
+
.badge-danger {
330
330
+
background: rgba(220, 38, 38, 0.1);
331
331
+
color: var(--danger-color);
332
332
+
}
104
333
105
105
-
.empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; }
334
334
+
.empty-state {
335
335
+
text-align: center;
336
336
+
padding: 40px 20px;
337
337
+
color: var(--secondary-color);
338
338
+
font-size: 0.875rem;
339
339
+
}
340
340
+
341
341
+
.load-more {
342
342
+
text-align: center;
343
343
+
padding: 16px;
344
344
+
}
345
345
+
346
346
+
.load-more a {
347
347
+
color: var(--brand-color);
348
348
+
text-decoration: none;
349
349
+
font-size: 0.875rem;
350
350
+
font-weight: 500;
351
351
+
}
106
352
107
107
-
.load-more { text-align: center; padding: 16px; }
108
108
-
.load-more a { color: var(--brand-color); text-decoration: none; font-size: 0.875rem; font-weight: 500; }
109
109
-
.load-more a:hover { text-decoration: underline; }
353
353
+
.load-more a:hover {
354
354
+
text-decoration: underline;
355
355
+
}
110
356
111
357
@media (max-width: 768px) {
112
112
-
.sidebar { display: none; }
113
113
-
.main { margin-left: 0; }
358
358
+
.sidebar {
359
359
+
display: none;
360
360
+
}
361
361
+
362
362
+
.main {
363
363
+
margin-left: 0;
364
364
+
}
114
365
}
115
366
</style>
116
367
</head>
117
368
<body>
118
118
-
<div class="layout">
119
119
-
<aside class="sidebar">
120
120
-
<div class="sidebar-title">{{pds_hostname}}</div>
121
121
-
<div class="sidebar-subtitle">Admin Portal</div>
122
122
-
<nav>
123
123
-
<a href="/admin/">Dashboard</a>
124
124
-
{{#if can_view_accounts}}
369
369
+
<div class="layout">
370
370
+
<aside class="sidebar">
371
371
+
<div class="sidebar-title">{{pds_hostname}}</div>
372
372
+
<div class="sidebar-subtitle">Admin Portal</div>
373
373
+
<nav>
374
374
+
<a href="/admin/dashboard">Dashboard</a>
375
375
+
{{#if can_view_accounts}}
125
376
<a href="/admin/accounts">Accounts</a>
126
126
-
{{/if}}
127
127
-
{{#if can_manage_invites}}
377
377
+
{{/if}}
378
378
+
{{#if can_manage_invites}}
128
379
<a href="/admin/invite-codes" class="active">Invite Codes</a>
129
129
-
{{/if}}
130
130
-
{{#if can_create_account}}
380
380
+
{{/if}}
381
381
+
{{#if can_create_account}}
131
382
<a href="/admin/create-account">Create Account</a>
132
132
-
{{/if}}
133
133
-
{{#if can_request_crawl}}
383
383
+
{{/if}}
384
384
+
{{#if can_request_crawl}}
134
385
<a href="/admin/request-crawl">Request Crawl</a>
135
135
-
{{/if}}
136
136
-
</nav>
137
137
-
<div class="sidebar-footer">
138
138
-
<div class="session-info">Signed in as {{handle}}</div>
139
139
-
<form method="POST" action="/admin/logout">
140
140
-
<button type="submit">Sign out</button>
141
141
-
</form>
142
142
-
</div>
143
143
-
</aside>
386
386
+
{{/if}}
387
387
+
</nav>
388
388
+
<div class="sidebar-footer">
389
389
+
<div class="session-info">Signed in as {{handle}}</div>
390
390
+
<form method="POST" action="/admin/logout">
391
391
+
<button type="submit">Sign out</button>
392
392
+
</form>
393
393
+
</div>
394
394
+
</aside>
144
395
145
145
-
<main class="main">
146
146
-
{{#if flash_success}}
396
396
+
<main class="main">
397
397
+
{{#if flash_success}}
147
398
<div class="flash-success">{{flash_success}}</div>
148
148
-
{{/if}}
149
149
-
{{#if flash_error}}
399
399
+
{{/if}}
400
400
+
{{#if flash_error}}
150
401
<div class="flash-error">{{flash_error}}</div>
151
151
-
{{/if}}
402
402
+
{{/if}}
152
403
153
153
-
<h1 class="page-title">Invite Codes</h1>
404
404
+
<h1 class="page-title">Invite Codes</h1>
154
405
155
155
-
{{#if can_create_invite}}
406
406
+
{{#if can_create_invite}}
156
407
<form class="create-form" method="POST" action="/admin/invite-codes/create">
157
408
<div class="form-group">
158
409
<label for="use_count">Max Uses</label>
159
159
-
<input type="number" id="use_count" name="use_count" value="1" min="1" max="100" />
410
410
+
<input type="number" id="use_count" name="use_count" value="1" min="1" max="100"/>
160
411
</div>
161
412
<button type="submit" class="btn btn-primary">Create Invite Code</button>
162
413
</form>
163
163
-
{{/if}}
414
414
+
{{/if}}
164
415
165
165
-
{{#if new_code}}
416
416
+
{{#if new_code}}
166
417
<div class="code-box">
167
418
<div class="code-label">New Invite Code Created</div>
168
419
<div class="code-value">{{new_code}}</div>
169
420
</div>
170
170
-
{{/if}}
421
421
+
{{/if}}
171
422
172
172
-
{{#if codes}}
423
423
+
{{#if codes}}
173
424
<div class="table-container">
174
425
<table>
175
426
<thead>
176
176
-
<tr>
177
177
-
<th>Code</th>
178
178
-
<th>Remaining / Total</th>
179
179
-
<th>Status</th>
180
180
-
<th>Created By</th>
181
181
-
<th>Created At</th>
182
182
-
{{#if can_manage_invites}}
427
427
+
<tr>
428
428
+
<th>Code</th>
429
429
+
<th>Remaining / Total</th>
430
430
+
<th>Status</th>
431
431
+
<th>Created By</th>
432
432
+
<th>Created At</th>
433
433
+
{{#if can_manage_invites}}
183
434
<th></th>
184
184
-
{{/if}}
185
185
-
</tr>
435
435
+
{{/if}}
436
436
+
</tr>
186
437
</thead>
187
438
<tbody>
188
188
-
{{#each codes}}
439
439
+
{{#each codes}}
189
440
<tr>
190
441
<td class="code-cell">{{this.code}}</td>
191
442
<td>{{this.remaining}} / {{this.available}}</td>
192
443
<td>
193
444
{{#if this.disabled}}
194
194
-
<span class="badge badge-danger">Disabled</span>
445
445
+
<span class="badge badge-danger">Disabled</span>
195
446
{{else}}
196
196
-
<span class="badge badge-success">Active</span>
447
447
+
<span class="badge badge-success">Active</span>
197
448
{{/if}}
198
449
</td>
199
450
<td>{{this.createdBy}}</td>
200
451
<td>{{this.createdAt}}</td>
201
452
{{#if ../can_manage_invites}}
202
202
-
<td>
203
203
-
{{#unless this.disabled}}
204
204
-
<form method="POST" action="/admin/invite-codes/disable" style="display:inline;">
205
205
-
<input type="hidden" name="codes" value="{{this.code}}" />
206
206
-
<button type="submit" class="btn btn-small btn-outline-danger">Disable</button>
207
207
-
</form>
208
208
-
{{/unless}}
209
209
-
</td>
453
453
+
<td>
454
454
+
{{#unless this.disabled}}
455
455
+
<form method="POST" action="/admin/invite-codes/disable"
456
456
+
style="display:inline;">
457
457
+
<input type="hidden" name="codes" value="{{this.code}}"/>
458
458
+
<button type="submit" class="btn btn-small btn-outline-danger">Disable
459
459
+
</button>
460
460
+
</form>
461
461
+
{{/unless}}
462
462
+
</td>
210
463
{{/if}}
211
464
</tr>
212
212
-
{{/each}}
465
465
+
{{/each}}
213
466
</tbody>
214
467
</table>
215
468
</div>
216
469
{{#if has_more}}
217
217
-
<div class="load-more">
218
218
-
<a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a>
219
219
-
</div>
470
470
+
<div class="load-more">
471
471
+
<a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a>
472
472
+
</div>
220
473
{{/if}}
221
221
-
{{else}}
474
474
+
{{else}}
222
475
<div class="empty-state">No invite codes found</div>
223
223
-
{{/if}}
224
224
-
</main>
225
225
-
</div>
476
476
+
{{/if}}
477
477
+
</main>
478
478
+
</div>
226
479
</body>
227
480
</html>
···
42
42
--table-stripe: rgba(255, 255, 255, 0.02);
43
43
}
44
44
45
45
-
* { margin: 0; padding: 0; box-sizing: border-box; }
45
45
+
* {
46
46
+
margin: 0;
47
47
+
padding: 0;
48
48
+
box-sizing: border-box;
49
49
+
}
46
50
47
51
body {
48
52
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
···
52
56
-webkit-font-smoothing: antialiased;
53
57
}
54
58
55
55
-
.layout { display: flex; min-height: 100vh; }
59
59
+
.layout {
60
60
+
display: flex;
61
61
+
min-height: 100vh;
62
62
+
}
56
63
57
57
-
.sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; }
58
58
-
.sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
59
59
-
.sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; }
60
60
-
.sidebar nav { flex: 1; }
61
61
-
.sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; }
62
62
-
.sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); }
63
63
-
.sidebar nav a.active { color: var(--brand-color); font-weight: 500; }
64
64
-
.sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; }
65
65
-
.sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; }
66
66
-
.sidebar-footer form { display: inline; }
67
67
-
.sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; }
68
68
-
.sidebar-footer button:hover { color: var(--primary-color); }
64
64
+
.sidebar {
65
65
+
width: 220px;
66
66
+
background: var(--bg-primary-color);
67
67
+
border-right: 1px solid var(--border-color);
68
68
+
padding: 20px 0;
69
69
+
position: fixed;
70
70
+
top: 0;
71
71
+
left: 0;
72
72
+
bottom: 0;
73
73
+
overflow-y: auto;
74
74
+
display: flex;
75
75
+
flex-direction: column;
76
76
+
}
69
77
70
70
-
.main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; }
71
71
-
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
72
72
-
.page-description { font-size: 0.875rem; color: var(--secondary-color); margin-bottom: 24px; }
78
78
+
.sidebar-title {
79
79
+
font-size: 0.8125rem;
80
80
+
font-weight: 700;
81
81
+
padding: 0 20px;
82
82
+
margin-bottom: 4px;
83
83
+
white-space: nowrap;
84
84
+
overflow: hidden;
85
85
+
text-overflow: ellipsis;
86
86
+
}
73
87
74
74
-
.flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
75
75
-
.flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; }
88
88
+
.sidebar-subtitle {
89
89
+
font-size: 0.6875rem;
90
90
+
color: var(--secondary-color);
91
91
+
padding: 0 20px;
92
92
+
margin-bottom: 20px;
93
93
+
}
76
94
77
77
-
.form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; }
78
78
-
.form-group { margin-bottom: 16px; }
79
79
-
.form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); }
80
80
-
.form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; }
81
81
-
.form-group input:focus { border-color: var(--brand-color); }
82
82
-
.form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; }
95
95
+
.sidebar nav {
96
96
+
flex: 1;
97
97
+
}
83
98
84
84
-
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; }
85
85
-
.btn:hover { opacity: 0.85; }
86
86
-
.btn-primary { background: var(--brand-color); color: #fff; }
99
99
+
.sidebar nav a {
100
100
+
display: block;
101
101
+
padding: 8px 20px;
102
102
+
font-size: 0.8125rem;
103
103
+
color: var(--secondary-color);
104
104
+
text-decoration: none;
105
105
+
transition: background 0.1s, color 0.1s;
106
106
+
}
107
107
+
108
108
+
.sidebar nav a:hover {
109
109
+
background: var(--bg-secondary-color);
110
110
+
color: var(--primary-color);
111
111
+
}
112
112
+
113
113
+
.sidebar nav a.active {
114
114
+
color: var(--brand-color);
115
115
+
font-weight: 500;
116
116
+
}
117
117
+
118
118
+
.sidebar-footer {
119
119
+
padding: 16px 20px 0;
120
120
+
border-top: 1px solid var(--border-color);
121
121
+
margin-top: 16px;
122
122
+
}
123
123
+
124
124
+
.sidebar-footer .session-info {
125
125
+
font-size: 0.75rem;
126
126
+
color: var(--secondary-color);
127
127
+
margin-bottom: 8px;
128
128
+
}
129
129
+
130
130
+
.sidebar-footer form {
131
131
+
display: inline;
132
132
+
}
133
133
+
134
134
+
.sidebar-footer button {
135
135
+
background: none;
136
136
+
border: none;
137
137
+
font-size: 0.75rem;
138
138
+
color: var(--secondary-color);
139
139
+
cursor: pointer;
140
140
+
padding: 0;
141
141
+
text-decoration: underline;
142
142
+
}
143
143
+
144
144
+
.sidebar-footer button:hover {
145
145
+
color: var(--primary-color);
146
146
+
}
147
147
+
148
148
+
.main {
149
149
+
margin-left: 220px;
150
150
+
flex: 1;
151
151
+
padding: 32px;
152
152
+
max-width: 960px;
153
153
+
}
154
154
+
155
155
+
.page-title {
156
156
+
font-size: 1.5rem;
157
157
+
font-weight: 700;
158
158
+
margin-bottom: 8px;
159
159
+
}
160
160
+
161
161
+
.page-description {
162
162
+
font-size: 0.875rem;
163
163
+
color: var(--secondary-color);
164
164
+
margin-bottom: 24px;
165
165
+
}
166
166
+
167
167
+
.flash-success {
168
168
+
background: rgba(22, 163, 74, 0.1);
169
169
+
color: var(--success-color);
170
170
+
border: 1px solid rgba(22, 163, 74, 0.2);
171
171
+
border-radius: 8px;
172
172
+
padding: 10px 14px;
173
173
+
font-size: 0.875rem;
174
174
+
margin-bottom: 20px;
175
175
+
}
176
176
+
177
177
+
.flash-error {
178
178
+
background: rgba(220, 38, 38, 0.1);
179
179
+
color: var(--danger-color);
180
180
+
border: 1px solid rgba(220, 38, 38, 0.2);
181
181
+
border-radius: 8px;
182
182
+
padding: 10px 14px;
183
183
+
font-size: 0.875rem;
184
184
+
margin-bottom: 20px;
185
185
+
}
186
186
+
187
187
+
.form-card {
188
188
+
background: var(--bg-primary-color);
189
189
+
border: 1px solid var(--border-color);
190
190
+
border-radius: 10px;
191
191
+
padding: 24px;
192
192
+
max-width: 480px;
193
193
+
}
194
194
+
195
195
+
.form-group {
196
196
+
margin-bottom: 16px;
197
197
+
}
198
198
+
199
199
+
.form-group label {
200
200
+
display: block;
201
201
+
font-size: 0.8125rem;
202
202
+
font-weight: 500;
203
203
+
margin-bottom: 6px;
204
204
+
color: var(--primary-color);
205
205
+
}
206
206
+
207
207
+
.form-group input {
208
208
+
width: 100%;
209
209
+
padding: 10px 12px;
210
210
+
font-size: 0.875rem;
211
211
+
border: 1px solid var(--border-color);
212
212
+
border-radius: 8px;
213
213
+
background: var(--bg-primary-color);
214
214
+
color: var(--primary-color);
215
215
+
outline: none;
216
216
+
transition: border-color 0.15s;
217
217
+
}
218
218
+
219
219
+
.form-group input:focus {
220
220
+
border-color: var(--brand-color);
221
221
+
}
222
222
+
223
223
+
.form-group .hint {
224
224
+
font-size: 0.75rem;
225
225
+
color: var(--secondary-color);
226
226
+
margin-top: 4px;
227
227
+
}
228
228
+
229
229
+
.btn {
230
230
+
display: inline-flex;
231
231
+
align-items: center;
232
232
+
justify-content: center;
233
233
+
padding: 10px 20px;
234
234
+
font-size: 0.875rem;
235
235
+
font-weight: 500;
236
236
+
border: none;
237
237
+
border-radius: 8px;
238
238
+
cursor: pointer;
239
239
+
transition: opacity 0.15s;
240
240
+
text-decoration: none;
241
241
+
}
242
242
+
243
243
+
.btn:hover {
244
244
+
opacity: 0.85;
245
245
+
}
246
246
+
247
247
+
.btn-primary {
248
248
+
background: var(--brand-color);
249
249
+
color: #fff;
250
250
+
}
87
251
88
252
@media (max-width: 768px) {
89
89
-
.sidebar { display: none; }
90
90
-
.main { margin-left: 0; }
253
253
+
.sidebar {
254
254
+
display: none;
255
255
+
}
256
256
+
257
257
+
.main {
258
258
+
margin-left: 0;
259
259
+
}
91
260
}
92
261
</style>
93
262
</head>
94
263
<body>
95
95
-
<div class="layout">
96
96
-
<aside class="sidebar">
97
97
-
<div class="sidebar-title">{{pds_hostname}}</div>
98
98
-
<div class="sidebar-subtitle">Admin Portal</div>
99
99
-
<nav>
100
100
-
<a href="/admin/">Dashboard</a>
101
101
-
{{#if can_view_accounts}}
264
264
+
<div class="layout">
265
265
+
<aside class="sidebar">
266
266
+
<div class="sidebar-title">{{pds_hostname}}</div>
267
267
+
<div class="sidebar-subtitle">Admin Portal</div>
268
268
+
<nav>
269
269
+
<a href="/admin/dashboard">Dashboard</a>
270
270
+
{{#if can_view_accounts}}
102
271
<a href="/admin/accounts">Accounts</a>
103
103
-
{{/if}}
104
104
-
{{#if can_manage_invites}}
272
272
+
{{/if}}
273
273
+
{{#if can_manage_invites}}
105
274
<a href="/admin/invite-codes">Invite Codes</a>
106
106
-
{{/if}}
107
107
-
{{#if can_create_account}}
275
275
+
{{/if}}
276
276
+
{{#if can_create_account}}
108
277
<a href="/admin/create-account">Create Account</a>
109
109
-
{{/if}}
110
110
-
{{#if can_request_crawl}}
278
278
+
{{/if}}
279
279
+
{{#if can_request_crawl}}
111
280
<a href="/admin/request-crawl" class="active">Request Crawl</a>
112
112
-
{{/if}}
113
113
-
</nav>
114
114
-
<div class="sidebar-footer">
115
115
-
<div class="session-info">Signed in as {{handle}}</div>
116
116
-
<form method="POST" action="/admin/logout">
117
117
-
<button type="submit">Sign out</button>
118
118
-
</form>
119
119
-
</div>
120
120
-
</aside>
281
281
+
{{/if}}
282
282
+
</nav>
283
283
+
<div class="sidebar-footer">
284
284
+
<div class="session-info">Signed in as {{handle}}</div>
285
285
+
<form method="POST" action="/admin/logout">
286
286
+
<button type="submit">Sign out</button>
287
287
+
</form>
288
288
+
</div>
289
289
+
</aside>
121
290
122
122
-
<main class="main">
123
123
-
{{#if flash_success}}
291
291
+
<main class="main">
292
292
+
{{#if flash_success}}
124
293
<div class="flash-success">{{flash_success}}</div>
125
125
-
{{/if}}
126
126
-
{{#if flash_error}}
294
294
+
{{/if}}
295
295
+
{{#if flash_error}}
127
296
<div class="flash-error">{{flash_error}}</div>
128
128
-
{{/if}}
297
297
+
{{/if}}
129
298
130
130
-
<h1 class="page-title">Request Crawl</h1>
131
131
-
<p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it can discover and index your content.</p>
299
299
+
<h1 class="page-title">Request Crawl</h1>
300
300
+
<p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it
301
301
+
can discover and index your content.</p>
132
302
133
133
-
<div class="form-card">
134
134
-
<form method="POST" action="/admin/request-crawl">
135
135
-
<div class="form-group">
136
136
-
<label for="relay_host">Relay Host</label>
137
137
-
<input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required />
138
138
-
<div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div>
139
139
-
</div>
140
140
-
<button type="submit" class="btn btn-primary">Request Crawl</button>
141
141
-
</form>
142
142
-
</div>
143
143
-
</main>
144
144
-
</div>
303
303
+
<div class="form-card">
304
304
+
<form method="POST" action="/admin/request-crawl">
305
305
+
<div class="form-group">
306
306
+
<label for="relay_host">Relay Host</label>
307
307
+
<input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required/>
308
308
+
<div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div>
309
309
+
</div>
310
310
+
<button type="submit" class="btn btn-primary">Request Crawl</button>
311
311
+
</form>
312
312
+
</div>
313
313
+
</main>
314
314
+
</div>
145
315
</body>
146
316
</html>
···
1
1
+
-- OAuth client session storage (replaces in-memory MemoryAuthStore)
2
2
+
CREATE TABLE IF NOT EXISTS oauth_client_sessions (
3
3
+
session_key VARCHAR NOT NULL PRIMARY KEY,
4
4
+
did VARCHAR NOT NULL,
5
5
+
session_id VARCHAR NOT NULL,
6
6
+
data TEXT NOT NULL,
7
7
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
8
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
9
9
+
);
10
10
+
11
11
+
CREATE INDEX IF NOT EXISTS idx_oauth_client_sessions_did ON oauth_client_sessions(did);
12
12
+
13
13
+
-- OAuth authorization request storage (transient, keyed by state token)
14
14
+
CREATE TABLE IF NOT EXISTS oauth_auth_requests (
15
15
+
state VARCHAR NOT NULL PRIMARY KEY,
16
16
+
data TEXT NOT NULL,
17
17
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
18
18
+
);
···
4
4
pub mod rbac;
5
5
pub mod routes;
6
6
pub mod session;
7
7
+
pub mod store;
7
8
8
9
use axum::{Router, middleware as ax_middleware, routing::get, routing::post};
9
10
···
15
16
pub fn router(state: AppState) -> Router<AppState> {
16
17
// Routes that do NOT require authentication
17
18
let public_routes = Router::new()
19
19
+
.route("/", get(routes::dashboard))
18
20
.route("/login", get(oauth::get_login).post(oauth::post_login))
19
21
.route("/oauth/callback", get(oauth::oauth_callback))
20
22
.route("/client-metadata.json", get(oauth::client_metadata_json));
21
23
22
24
// Routes that DO require authentication (via admin_auth middleware)
23
25
let protected_routes = Router::new()
24
24
-
.route("/", get(routes::dashboard))
26
26
+
.route("/dashboard", get(routes::dashboard))
25
27
.route("/accounts", get(routes::accounts_list))
26
28
.route("/accounts/{did}", get(routes::account_detail))
27
27
-
.route(
28
28
-
"/accounts/{did}/takedown",
29
29
-
post(routes::takedown_account),
30
30
-
)
29
29
+
.route("/accounts/{did}/takedown", post(routes::takedown_account))
31
30
.route(
32
31
"/accounts/{did}/untakedown",
33
32
post(routes::untakedown_account),
34
33
)
35
35
-
.route(
36
36
-
"/accounts/{did}/delete",
37
37
-
post(routes::delete_account),
38
38
-
)
34
34
+
.route("/accounts/{did}/delete", post(routes::delete_account))
39
35
.route(
40
36
"/accounts/{did}/reset-password",
41
37
post(routes::reset_password),
···
50
46
)
51
47
.route("/invite-codes", get(routes::invite_codes_list))
52
48
.route("/invite-codes/create", post(routes::create_invite_code))
53
53
-
.route(
54
54
-
"/invite-codes/disable",
55
55
-
post(routes::disable_invite_codes),
56
56
-
)
49
49
+
.route("/invite-codes/disable", post(routes::disable_invite_codes))
57
50
.route(
58
51
"/create-account",
59
52
get(routes::get_create_account).post(routes::post_create_account),
···
64
57
get(routes::get_request_crawl).post(routes::post_request_crawl),
65
58
)
66
59
.route("/logout", post(routes::logout))
60
60
+
.fallback(get(routes::dashboard))
67
61
.layer(ax_middleware::from_fn_with_state(
68
62
state.clone(),
69
63
middleware::admin_auth_middleware,
···
1
1
+
use super::session;
1
2
use crate::AppState;
3
3
+
use crate::admin::store::SqlAuthStore;
2
4
use axum::{
3
5
extract::{Query, State},
4
6
http::StatusCode,
5
7
response::{Html, IntoResponse, Redirect, Response},
6
8
};
7
9
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
8
8
-
use base64::Engine as _;
9
10
use jacquard_identity::JacquardResolver;
11
11
+
use jacquard_oauth::session::ClientSessionData;
10
12
use jacquard_oauth::{
11
13
atproto::{AtprotoClientMetadata, GrantType},
12
12
-
authstore::MemoryAuthStore,
13
14
client::OAuthClient,
14
14
-
keyset::Keyset,
15
15
session::ClientData,
16
16
types::{AuthorizeOptions, CallbackParams},
17
17
};
18
18
-
use jose_jwk::Jwk;
19
18
use serde::Deserialize;
19
19
+
use sqlx::SqlitePool;
20
20
use tracing::log;
21
21
22
22
-
use super::session;
23
23
-
24
22
/// Type alias for the concrete OAuthClient we use.
25
25
-
pub type AdminOAuthClient = OAuthClient<JacquardResolver, MemoryAuthStore>;
23
23
+
pub type AdminOAuthClient = OAuthClient<JacquardResolver, SqlAuthStore>;
26
24
27
25
/// Initialize the OAuth client for admin portal authentication.
28
28
-
pub fn init_oauth_client(pds_hostname: &str) -> Result<AdminOAuthClient, anyhow::Error> {
26
26
+
pub fn init_oauth_client(
27
27
+
pds_hostname: &str,
28
28
+
pool: SqlitePool,
29
29
+
) -> Result<AdminOAuthClient, anyhow::Error> {
29
30
// Build client metadata
30
31
let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname)
31
32
.parse()
···
47
48
);
48
49
49
50
let client_data = ClientData::new(None, config);
50
50
-
let store = MemoryAuthStore::new();
51
51
+
let store = SqlAuthStore::new(pool);
51
52
let client = OAuthClient::new(store, client_data);
52
53
53
54
Ok(client)
···
55
56
56
57
/// GET /admin/client-metadata.json — Serves the OAuth client metadata.
57
58
pub async fn client_metadata_json(State(state): State<AppState>) -> Response {
58
58
-
let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client {
59
59
-
Some(client) => client,
60
60
-
None => return StatusCode::NOT_FOUND.into_response(),
61
61
-
};
62
62
-
63
59
let pds_hostname = &state.app_config.pds_hostname;
64
60
let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname);
65
61
let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname);
···
240
236
241
237
let updated_jar = jar.add(cookie);
242
238
243
243
-
(updated_jar, Redirect::to("/admin/")).into_response()
239
239
+
(updated_jar, Redirect::to("/admin/dashboard")).into_response()
244
240
}
245
241
246
242
fn render_error(state: &AppState, title: &str, message: &str) -> Response {
···
67
67
// ─── Helper functions ────────────────────────────────────────────────────────
68
68
69
69
fn admin_password(state: &AppState) -> &str {
70
70
-
state
71
71
-
.app_config
72
72
-
.pds_admin_password
73
73
-
.as_deref()
74
74
-
.unwrap_or("")
70
70
+
state.app_config.pds_admin_password.as_deref().unwrap_or("")
75
71
}
76
72
77
73
fn pds_url(state: &AppState) -> &str {
···
126
122
"can_create_invite".into(),
127
123
permissions.can_create_invite.into(),
128
124
);
129
129
-
obj.insert(
130
130
-
"can_send_email".into(),
131
131
-
permissions.can_send_email.into(),
132
132
-
);
125
125
+
obj.insert("can_send_email".into(), permissions.can_send_email.into());
133
126
obj.insert(
134
127
"can_request_crawl".into(),
135
128
permissions.can_request_crawl.into(),
···
140
133
let mut url = base_path.to_string();
141
134
let mut sep = '?';
142
135
if let Some(msg) = success {
143
143
-
url.push_str(&format!("{}flash_success={}", sep, urlencoding::encode(msg)));
136
136
+
url.push_str(&format!(
137
137
+
"{}flash_success={}",
138
138
+
sep,
139
139
+
urlencoding::encode(msg)
140
140
+
));
144
141
sep = '&';
145
142
}
146
143
if let Some(msg) = error {
···
160
157
161
158
// ─── Route handlers ──────────────────────────────────────────────────────────
162
159
163
163
-
/// GET /admin/ — Dashboard
160
160
+
/// GET /admin/dashboard — Dashboard
164
161
pub async fn dashboard(
165
162
State(state): State<AppState>,
166
163
Extension(session): Extension<AdminSession>,
···
370
367
Query(params): Query<AccountDetailParams>,
371
368
) -> Response {
372
369
if !permissions.can_view_accounts {
373
373
-
return flash_redirect("/admin/", None, Some("Access denied"));
370
370
+
return flash_redirect("/admin/dashboard", None, Some("Access denied"));
374
371
}
375
372
376
373
let pds = pds_url(&state);
···
451
448
452
449
// Gap 2: extract repo status data
453
450
let repo_status = repo_status_res.ok();
454
454
-
let repo_active = repo_status
455
455
-
.as_ref()
456
456
-
.and_then(|r| r["active"].as_bool());
451
451
+
let repo_active = repo_status.as_ref().and_then(|r| r["active"].as_bool());
457
452
let repo_status_reason = repo_status
458
453
.as_ref()
459
454
.and_then(|r| r["status"].as_str())
···
500
495
}
501
496
502
497
// Threat signatures
503
503
-
if threat_signatures.is_array()
504
504
-
&& !threat_signatures.as_array().unwrap().is_empty()
505
505
-
{
498
498
+
if threat_signatures.is_array() && !threat_signatures.as_array().unwrap().is_empty() {
506
499
data["threat_signatures"] = threat_signatures;
507
500
}
508
501
···
858
851
.map(|code| {
859
852
let mut c = code.clone();
860
853
let available = c["available"].as_i64().unwrap_or(0);
861
861
-
let used_count = c["uses"]
862
862
-
.as_array()
863
863
-
.map(|u| u.len() as i64)
864
864
-
.unwrap_or(0);
854
854
+
let used_count = c["uses"].as_array().map(|u| u.len() as i64).unwrap_or(0);
865
855
let remaining = (available - used_count).max(0);
866
856
c["used_count"] = used_count.into();
867
857
c["remaining"] = remaining.into();
···
976
966
)
977
967
.await
978
968
{
979
979
-
Ok(()) => flash_redirect(
980
980
-
"/admin/invite-codes",
981
981
-
Some("Invite codes disabled"),
982
982
-
None,
983
983
-
),
984
984
-
Err(e) => flash_redirect(
985
985
-
"/admin/invite-codes",
986
986
-
None,
987
987
-
Some(&format!("Failed: {}", e)),
988
988
-
),
969
969
+
Ok(()) => flash_redirect("/admin/invite-codes", Some("Invite codes disabled"), None),
970
970
+
Err(e) => flash_redirect("/admin/invite-codes", None, Some(&format!("Failed: {}", e))),
989
971
}
990
972
}
991
973
···
1061
1043
}
1062
1044
};
1063
1045
1064
1064
-
let invite_code = invite_res["code"]
1065
1065
-
.as_str()
1066
1066
-
.unwrap_or("")
1067
1067
-
.to_string();
1046
1046
+
let invite_code = invite_res["code"].as_str().unwrap_or("").to_string();
1068
1047
1069
1048
// Step 2: Create account
1070
1049
let account_password = generate_random_password();
···
1223
1202
"hostname": pds_hostname,
1224
1203
});
1225
1204
1226
1226
-
match pds_proxy::public_xrpc_post(
1227
1227
-
&relay_base,
1228
1228
-
"com.atproto.sync.requestCrawl",
1229
1229
-
&body,
1230
1230
-
)
1231
1231
-
.await
1232
1232
-
{
1205
1205
+
match pds_proxy::public_xrpc_post(&relay_base, "com.atproto.sync.requestCrawl", &body).await {
1233
1206
Ok(()) => flash_redirect(
1234
1207
"/admin/request-crawl",
1235
1208
Some(&format!(
···
1247
1220
}
1248
1221
1249
1222
/// POST /admin/logout — Clear session and redirect to login
1250
1250
-
pub async fn logout(
1251
1251
-
State(state): State<AppState>,
1252
1252
-
jar: SignedCookieJar,
1253
1253
-
) -> Response {
1223
1223
+
pub async fn logout(State(state): State<AppState>, jar: SignedCookieJar) -> Response {
1254
1224
if let Some(cookie) = jar.get("__gatekeeper_admin_session") {
1255
1225
let session_id = cookie.value().to_string();
1256
1226
let _ = session::delete_session(&state.pds_gatekeeper_pool, &session_id).await;
1257
1227
}
1258
1228
1259
1229
let mut removal = Cookie::build("__gatekeeper_admin_session")
1260
1260
-
.path("/admin/")
1230
1230
+
.path("/admin/dashboard")
1261
1231
.build();
1262
1232
removal.make_removal();
1263
1233
···
1
1
+
use jacquard_common::IntoStatic;
2
2
+
use jacquard_common::session::SessionStoreError;
3
3
+
use jacquard_common::types::did::Did;
4
4
+
use jacquard_oauth::authstore::ClientAuthStore;
5
5
+
use jacquard_oauth::session::{AuthRequestData, ClientSessionData};
6
6
+
use sqlx::SqlitePool;
7
7
+
8
8
+
fn sqlx_to_session_err(e: sqlx::Error) -> SessionStoreError {
9
9
+
SessionStoreError::Other(Box::new(e))
10
10
+
}
11
11
+
12
12
+
#[derive(Clone)]
13
13
+
pub struct SqlAuthStore {
14
14
+
pool: SqlitePool,
15
15
+
}
16
16
+
17
17
+
impl SqlAuthStore {
18
18
+
pub fn new(pool: SqlitePool) -> Self {
19
19
+
Self { pool }
20
20
+
}
21
21
+
}
22
22
+
23
23
+
impl ClientAuthStore for SqlAuthStore {
24
24
+
async fn get_session(
25
25
+
&self,
26
26
+
did: &Did<'_>,
27
27
+
session_id: &str,
28
28
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> {
29
29
+
let key = format!("{}_{}", did, session_id);
30
30
+
31
31
+
let row: Option<(String,)> =
32
32
+
sqlx::query_as("SELECT data FROM oauth_client_sessions WHERE session_key = ?")
33
33
+
.bind(&key)
34
34
+
.fetch_optional(&self.pool)
35
35
+
.await
36
36
+
.map_err(sqlx_to_session_err)?;
37
37
+
38
38
+
match row {
39
39
+
Some((json_data,)) => {
40
40
+
let session: ClientSessionData<'_> = serde_json::from_str(&json_data)?;
41
41
+
Ok(Some(session.into_static()))
42
42
+
}
43
43
+
None => Ok(None),
44
44
+
}
45
45
+
}
46
46
+
47
47
+
async fn upsert_session(
48
48
+
&self,
49
49
+
session: ClientSessionData<'_>,
50
50
+
) -> Result<(), SessionStoreError> {
51
51
+
let static_session = session.into_static();
52
52
+
let did = static_session.account_did.to_string();
53
53
+
let session_id = static_session.session_id.to_string();
54
54
+
let key = format!("{}_{}", did, session_id);
55
55
+
let json_data = serde_json::to_string(&static_session)?;
56
56
+
let now = chrono::Utc::now().to_rfc3339();
57
57
+
58
58
+
sqlx::query(
59
59
+
"INSERT INTO oauth_client_sessions (session_key, did, session_id, data, created_at, updated_at)
60
60
+
VALUES (?, ?, ?, ?, ?, ?)
61
61
+
ON CONFLICT(session_key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
62
62
+
)
63
63
+
.bind(&key)
64
64
+
.bind(&did)
65
65
+
.bind(&session_id)
66
66
+
.bind(&json_data)
67
67
+
.bind(&now)
68
68
+
.bind(&now)
69
69
+
.execute(&self.pool)
70
70
+
.await
71
71
+
.map_err(sqlx_to_session_err)?;
72
72
+
73
73
+
Ok(())
74
74
+
}
75
75
+
76
76
+
async fn delete_session(
77
77
+
&self,
78
78
+
did: &Did<'_>,
79
79
+
session_id: &str,
80
80
+
) -> Result<(), SessionStoreError> {
81
81
+
let key = format!("{}_{}", did, session_id);
82
82
+
83
83
+
sqlx::query("DELETE FROM oauth_client_sessions WHERE session_key = ?")
84
84
+
.bind(&key)
85
85
+
.execute(&self.pool)
86
86
+
.await
87
87
+
.map_err(sqlx_to_session_err)?;
88
88
+
89
89
+
Ok(())
90
90
+
}
91
91
+
92
92
+
async fn get_auth_req_info(
93
93
+
&self,
94
94
+
state: &str,
95
95
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> {
96
96
+
let row: Option<(String,)> =
97
97
+
sqlx::query_as("SELECT data FROM oauth_auth_requests WHERE state = ?")
98
98
+
.bind(state)
99
99
+
.fetch_optional(&self.pool)
100
100
+
.await
101
101
+
.map_err(sqlx_to_session_err)?;
102
102
+
103
103
+
match row {
104
104
+
Some((json_data,)) => {
105
105
+
let auth_req: AuthRequestData<'_> = serde_json::from_str(&json_data)?;
106
106
+
Ok(Some(auth_req.into_static()))
107
107
+
}
108
108
+
None => Ok(None),
109
109
+
}
110
110
+
}
111
111
+
112
112
+
async fn save_auth_req_info(
113
113
+
&self,
114
114
+
auth_req_info: &AuthRequestData<'_>,
115
115
+
) -> Result<(), SessionStoreError> {
116
116
+
let static_info = auth_req_info.clone().into_static();
117
117
+
let state = static_info.state.to_string();
118
118
+
let json_data = serde_json::to_string(&static_info)?;
119
119
+
let now = chrono::Utc::now().to_rfc3339();
120
120
+
121
121
+
sqlx::query("INSERT INTO oauth_auth_requests (state, data, created_at) VALUES (?, ?, ?)")
122
122
+
.bind(&state)
123
123
+
.bind(&json_data)
124
124
+
.bind(&now)
125
125
+
.execute(&self.pool)
126
126
+
.await
127
127
+
.map_err(sqlx_to_session_err)?;
128
128
+
129
129
+
Ok(())
130
130
+
}
131
131
+
132
132
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> {
133
133
+
sqlx::query("DELETE FROM oauth_auth_requests WHERE state = ?")
134
134
+
.bind(state)
135
135
+
.execute(&self.pool)
136
136
+
.await
137
137
+
.map_err(sqlx_to_session_err)?;
138
138
+
139
139
+
Ok(())
140
140
+
}
141
141
+
}
142
142
+
143
143
+
/// Delete auth requests older than the given number of minutes.
144
144
+
pub async fn cleanup_stale_auth_requests(
145
145
+
pool: &SqlitePool,
146
146
+
max_age_minutes: i64,
147
147
+
) -> Result<u64, sqlx::Error> {
148
148
+
let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(max_age_minutes)).to_rfc3339();
149
149
+
let result = sqlx::query("DELETE FROM oauth_auth_requests WHERE created_at < ?")
150
150
+
.bind(&cutoff)
151
151
+
.execute(pool)
152
152
+
.await?;
153
153
+
Ok(result.rows_affected())
154
154
+
}
···
307
307
let app_config = AppConfig::new();
308
308
309
309
// Admin portal setup (opt-in via GATEKEEPER_ADMIN_RBAC_CONFIG)
310
310
-
let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG")
311
311
-
.ok()
312
312
-
.map(|path| {
313
313
-
let config = admin::rbac::RbacConfig::load_from_file(&path)
314
314
-
.unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e));
315
315
-
log::info!("Loaded admin RBAC config from {} ({} members)", path, config.members.len());
316
316
-
Arc::new(config)
317
317
-
});
310
310
+
let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG").ok().map(|path| {
311
311
+
let config = admin::rbac::RbacConfig::load_from_file(&path)
312
312
+
.unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e));
313
313
+
log::info!(
314
314
+
"Loaded admin RBAC config from {} ({} members)",
315
315
+
path,
316
316
+
config.members.len()
317
317
+
);
318
318
+
Arc::new(config)
319
319
+
});
318
320
319
321
let admin_oauth_client = if admin_rbac_config.is_some() {
320
320
-
match admin::oauth::init_oauth_client(&app_config.pds_hostname) {
322
322
+
match admin::oauth::init_oauth_client(&app_config.pds_hostname, pds_gatekeeper_pool.clone())
323
323
+
{
321
324
Ok(client) => {
322
322
-
log::info!("Admin OAuth client initialized for {}", app_config.pds_hostname);
325
325
+
log::info!(
326
326
+
"Admin OAuth client initialized for {}",
327
327
+
app_config.pds_hostname
328
328
+
);
323
329
Some(Arc::new(client))
324
330
}
325
331
Err(e) => {
326
326
-
log::error!("Failed to initialize admin OAuth client: {}. Admin portal will be disabled.", e);
332
332
+
log::error!(
333
333
+
"Failed to initialize admin OAuth client: {}. Admin portal will be disabled.",
334
334
+
e
335
335
+
);
327
336
None
328
337
}
329
338
}
···
470
479
// Background cleanup for admin sessions
471
480
let cleanup_pool = state.pds_gatekeeper_pool.clone();
472
481
let admin_enabled = state.admin_rbac_config.is_some();
482
482
+
let admin_session_ttl_in_mins = state.app_config.admin_session_ttl_hours * 60;
473
483
tokio::spawn(async move {
474
484
let mut interval = tokio::time::interval(Duration::from_secs(300));
475
485
loop {
···
477
487
if admin_enabled {
478
488
if let Err(e) = admin::session::cleanup_expired_sessions(&cleanup_pool).await {
479
489
tracing::error!("Failed to cleanup expired admin sessions: {}", e);
490
490
+
}
491
491
+
if let Err(e) = admin::store::cleanup_stale_auth_requests(
492
492
+
&cleanup_pool,
493
493
+
admin_session_ttl_in_mins as i64,
494
494
+
)
495
495
+
.await
496
496
+
{
497
497
+
tracing::error!("Failed to cleanup stale OAuth auth requests: {}", e);
480
498
}
481
499
}
482
500
}