neoagent 2.4.1-beta.44 → 2.4.1-beta.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -0
- package/docs/configuration.md +6 -0
- package/flutter_app/lib/main_app_shell.dart +0 -2
- package/flutter_app/lib/main_chat.dart +1 -0
- package/flutter_app/lib/main_controller.dart +14 -2
- package/flutter_app/lib/main_models.dart +36 -0
- package/flutter_app/lib/main_navigation.dart +1 -9
- package/flutter_app/lib/main_settings.dart +4 -265
- package/flutter_app/lib/main_shared.dart +138 -3
- package/flutter_app/lib/main_unified.dart +5 -100
- package/lib/manager.js +32 -0
- package/package.json +1 -1
- package/runtime/paths.js +3 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.css +268 -0
- package/server/admin/admin.js +348 -0
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +993 -0
- package/server/admin/login.html +251 -0
- package/server/admin/logo.svg +43 -0
- package/server/admin/sql.js +134 -0
- package/server/admin/users.js +147 -0
- package/server/db/database.js +19 -0
- package/server/http/static.js +4 -1
- package/server/index.js +1 -1
- package/server/middleware/adminAuth.js +48 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +70538 -71095
- package/server/routes/admin.js +618 -0
- package/server/routes/settings.js +10 -2
- package/server/services/account/admin_two_factor.js +132 -0
- package/server/services/widgets/service.js +17 -1
- package/server/utils/logger.js +44 -6
|
@@ -639,6 +639,53 @@ class _PageTitle extends StatelessWidget {
|
|
|
639
639
|
}
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
IconData _runPhaseIcon(String phase) {
|
|
643
|
+
switch (phase.toLowerCase()) {
|
|
644
|
+
case 'thinking':
|
|
645
|
+
return Icons.psychology_outlined;
|
|
646
|
+
case 'analyzing':
|
|
647
|
+
return Icons.analytics_outlined;
|
|
648
|
+
case 'planning':
|
|
649
|
+
return Icons.list_alt_outlined;
|
|
650
|
+
case 'verifying':
|
|
651
|
+
return Icons.fact_check_outlined;
|
|
652
|
+
case 'streaming':
|
|
653
|
+
case 'responding':
|
|
654
|
+
return Icons.chat_outlined;
|
|
655
|
+
case 'awaiting approval':
|
|
656
|
+
return Icons.lock_clock_outlined;
|
|
657
|
+
case 'incorporating steering':
|
|
658
|
+
return Icons.tune_outlined;
|
|
659
|
+
case 'completed':
|
|
660
|
+
return Icons.check_circle_outline_rounded;
|
|
661
|
+
case 'stopped':
|
|
662
|
+
case 'stopping':
|
|
663
|
+
return Icons.stop_circle_outlined;
|
|
664
|
+
default:
|
|
665
|
+
return Icons.sync_outlined;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
Color _runPhaseColor(String phase) {
|
|
670
|
+
switch (phase.toLowerCase()) {
|
|
671
|
+
case 'thinking':
|
|
672
|
+
case 'analyzing':
|
|
673
|
+
case 'planning':
|
|
674
|
+
return _info;
|
|
675
|
+
case 'verifying':
|
|
676
|
+
return _accentAlt;
|
|
677
|
+
case 'awaiting approval':
|
|
678
|
+
return _warning;
|
|
679
|
+
case 'completed':
|
|
680
|
+
return _success;
|
|
681
|
+
case 'stopped':
|
|
682
|
+
case 'stopping':
|
|
683
|
+
return _textSecondary;
|
|
684
|
+
default:
|
|
685
|
+
return _accent;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
642
689
|
class _RunStatusPanel extends StatelessWidget {
|
|
643
690
|
const _RunStatusPanel({required this.run, required this.tools});
|
|
644
691
|
|
|
@@ -650,6 +697,9 @@ class _RunStatusPanel extends StatelessWidget {
|
|
|
650
697
|
final runningCount = tools.where((tool) => tool.status == 'running').length;
|
|
651
698
|
final helperCount = tools.where((tool) => tool.isHelperRelated).length;
|
|
652
699
|
final webCount = tools.where((tool) => tool.isWebRelated).length;
|
|
700
|
+
final phase = run?.phase ?? '';
|
|
701
|
+
final phaseColor = _runPhaseColor(phase);
|
|
702
|
+
final isDeepRun = (run?.iteration ?? 0) >= 8;
|
|
653
703
|
|
|
654
704
|
return Card(
|
|
655
705
|
child: Padding(
|
|
@@ -659,6 +709,30 @@ class _RunStatusPanel extends StatelessWidget {
|
|
|
659
709
|
children: <Widget>[
|
|
660
710
|
Row(
|
|
661
711
|
children: <Widget>[
|
|
712
|
+
if (phase.isNotEmpty) ...<Widget>[
|
|
713
|
+
_PulseHalo(
|
|
714
|
+
color: phaseColor,
|
|
715
|
+
animate: phase.toLowerCase() != 'completed' &&
|
|
716
|
+
phase.toLowerCase() != 'stopped',
|
|
717
|
+
child: Container(
|
|
718
|
+
width: 30,
|
|
719
|
+
height: 30,
|
|
720
|
+
decoration: BoxDecoration(
|
|
721
|
+
color: phaseColor.withValues(alpha: 0.14),
|
|
722
|
+
shape: BoxShape.circle,
|
|
723
|
+
border: Border.all(
|
|
724
|
+
color: phaseColor.withValues(alpha: 0.28),
|
|
725
|
+
),
|
|
726
|
+
),
|
|
727
|
+
child: Icon(
|
|
728
|
+
_runPhaseIcon(phase),
|
|
729
|
+
size: 16,
|
|
730
|
+
color: phaseColor,
|
|
731
|
+
),
|
|
732
|
+
),
|
|
733
|
+
),
|
|
734
|
+
const SizedBox(width: 12),
|
|
735
|
+
],
|
|
662
736
|
Expanded(
|
|
663
737
|
child: Column(
|
|
664
738
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
@@ -670,7 +744,7 @@ class _RunStatusPanel extends StatelessWidget {
|
|
|
670
744
|
fontWeight: FontWeight.w700,
|
|
671
745
|
),
|
|
672
746
|
),
|
|
673
|
-
const SizedBox(height:
|
|
747
|
+
const SizedBox(height: 4),
|
|
674
748
|
Text(
|
|
675
749
|
run == null
|
|
676
750
|
? 'Waiting for run events...'
|
|
@@ -679,7 +753,12 @@ class _RunStatusPanel extends StatelessWidget {
|
|
|
679
753
|
if (run!.pendingSteeringCount > 0)
|
|
680
754
|
'${run!.pendingSteeringCount} steering ${run!.pendingSteeringCount == 1 ? 'update' : 'updates'} queued',
|
|
681
755
|
].join(' · '),
|
|
682
|
-
style: TextStyle(
|
|
756
|
+
style: TextStyle(
|
|
757
|
+
color: phase.isNotEmpty
|
|
758
|
+
? phaseColor.withValues(alpha: 0.8)
|
|
759
|
+
: _textSecondary,
|
|
760
|
+
fontSize: 12.5,
|
|
761
|
+
),
|
|
683
762
|
),
|
|
684
763
|
],
|
|
685
764
|
),
|
|
@@ -714,6 +793,12 @@ class _RunStatusPanel extends StatelessWidget {
|
|
|
714
793
|
label: '$helperCount helpers',
|
|
715
794
|
icon: Icons.account_tree_outlined,
|
|
716
795
|
),
|
|
796
|
+
if (isDeepRun)
|
|
797
|
+
_MetaPill(
|
|
798
|
+
label: 'deep run · step ${run!.iteration}',
|
|
799
|
+
icon: Icons.warning_amber_outlined,
|
|
800
|
+
color: _warning,
|
|
801
|
+
),
|
|
717
802
|
],
|
|
718
803
|
),
|
|
719
804
|
const SizedBox(height: 14),
|
|
@@ -1479,10 +1564,15 @@ class _EmptyState extends StatelessWidget {
|
|
|
1479
1564
|
}
|
|
1480
1565
|
|
|
1481
1566
|
class _ChatBubble extends StatelessWidget {
|
|
1482
|
-
const _ChatBubble({
|
|
1567
|
+
const _ChatBubble({
|
|
1568
|
+
required this.entry,
|
|
1569
|
+
this.onLoadRunDetail,
|
|
1570
|
+
this.onSendMessage,
|
|
1571
|
+
});
|
|
1483
1572
|
|
|
1484
1573
|
final ChatEntry entry;
|
|
1485
1574
|
final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
|
|
1575
|
+
final void Function(String)? onSendMessage;
|
|
1486
1576
|
|
|
1487
1577
|
@override
|
|
1488
1578
|
Widget build(BuildContext context) {
|
|
@@ -1604,6 +1694,15 @@ class _ChatBubble extends StatelessWidget {
|
|
|
1604
1694
|
onLoadRunDetail: onLoadRunDetail,
|
|
1605
1695
|
),
|
|
1606
1696
|
],
|
|
1697
|
+
if (!isUser &&
|
|
1698
|
+
entry.richPayload != null &&
|
|
1699
|
+
onSendMessage != null) ...<Widget>[
|
|
1700
|
+
const SizedBox(height: 12),
|
|
1701
|
+
_QuickReplyBar(
|
|
1702
|
+
payload: entry.richPayload!,
|
|
1703
|
+
onSelected: onSendMessage!,
|
|
1704
|
+
),
|
|
1705
|
+
],
|
|
1607
1706
|
const SizedBox(height: 10),
|
|
1608
1707
|
Text(
|
|
1609
1708
|
entry.createdAtLabel,
|
|
@@ -3757,6 +3856,42 @@ String _summarizeToolResult(dynamic raw) {
|
|
|
3757
3856
|
return text.length > 140 ? '${text.substring(0, 140)}…' : text;
|
|
3758
3857
|
}
|
|
3759
3858
|
|
|
3859
|
+
class _QuickReplyBar extends StatelessWidget {
|
|
3860
|
+
const _QuickReplyBar({required this.payload, required this.onSelected});
|
|
3861
|
+
|
|
3862
|
+
final ChatRichPayload payload;
|
|
3863
|
+
final void Function(String value) onSelected;
|
|
3864
|
+
|
|
3865
|
+
@override
|
|
3866
|
+
Widget build(BuildContext context) {
|
|
3867
|
+
return Wrap(
|
|
3868
|
+
spacing: 8,
|
|
3869
|
+
runSpacing: 8,
|
|
3870
|
+
children: payload.options.map((option) {
|
|
3871
|
+
return GestureDetector(
|
|
3872
|
+
onTap: () => onSelected(option.value),
|
|
3873
|
+
child: Container(
|
|
3874
|
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
3875
|
+
decoration: BoxDecoration(
|
|
3876
|
+
color: _accentMuted,
|
|
3877
|
+
borderRadius: BorderRadius.circular(999),
|
|
3878
|
+
border: Border.all(color: _accent.withValues(alpha: 0.4)),
|
|
3879
|
+
),
|
|
3880
|
+
child: Text(
|
|
3881
|
+
option.label,
|
|
3882
|
+
style: TextStyle(
|
|
3883
|
+
fontSize: 13,
|
|
3884
|
+
color: _accent,
|
|
3885
|
+
fontWeight: FontWeight.w600,
|
|
3886
|
+
),
|
|
3887
|
+
),
|
|
3888
|
+
),
|
|
3889
|
+
);
|
|
3890
|
+
}).toList(growable: false),
|
|
3891
|
+
);
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3760
3895
|
String _titleCase(String value) {
|
|
3761
3896
|
final normalized = value.trim();
|
|
3762
3897
|
if (normalized.isEmpty) {
|
|
@@ -2,7 +2,7 @@ part of 'main.dart';
|
|
|
2
2
|
|
|
3
3
|
enum _ToolsPageTab { integrations, mcp, skills }
|
|
4
4
|
|
|
5
|
-
enum _RunsPageTab { runs
|
|
5
|
+
enum _RunsPageTab { runs }
|
|
6
6
|
|
|
7
7
|
enum _SettingsWorkspaceSection { app, account, security }
|
|
8
8
|
|
|
@@ -126,76 +126,7 @@ class RunsAndLogsPanel extends StatefulWidget {
|
|
|
126
126
|
State<RunsAndLogsPanel> createState() => _RunsAndLogsPanelState();
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
130
|
-
with SingleTickerProviderStateMixin {
|
|
131
|
-
late final TabController _tabController;
|
|
132
|
-
bool _syncingFromController = false;
|
|
133
|
-
|
|
134
|
-
@override
|
|
135
|
-
void initState() {
|
|
136
|
-
super.initState();
|
|
137
|
-
_tabController = TabController(
|
|
138
|
-
length: _RunsPageTab.values.length,
|
|
139
|
-
vsync: this,
|
|
140
|
-
initialIndex: _tabForSection(widget.controller.selectedSection).index,
|
|
141
|
-
)..addListener(_handleTabChanged);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
@override
|
|
145
|
-
void didUpdateWidget(covariant RunsAndLogsPanel oldWidget) {
|
|
146
|
-
super.didUpdateWidget(oldWidget);
|
|
147
|
-
final selectedSection = widget.controller.selectedSection;
|
|
148
|
-
if (selectedSection != oldWidget.controller.selectedSection &&
|
|
149
|
-
(selectedSection == AppSection.runs ||
|
|
150
|
-
selectedSection == AppSection.logs)) {
|
|
151
|
-
final targetIndex = _tabForSection(selectedSection).index;
|
|
152
|
-
if (_tabController.index != targetIndex) {
|
|
153
|
-
_syncingFromController = true;
|
|
154
|
-
_tabController.index = targetIndex;
|
|
155
|
-
_syncingFromController = false;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
@override
|
|
161
|
-
void dispose() {
|
|
162
|
-
_tabController.removeListener(_handleTabChanged);
|
|
163
|
-
_tabController.dispose();
|
|
164
|
-
super.dispose();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
void _handleTabChanged() {
|
|
168
|
-
if (_syncingFromController || _tabController.indexIsChanging) {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
_selectSectionForTabIndex(_tabController.index);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
void _selectSectionForTabIndex(int index) {
|
|
175
|
-
final section = _sectionForTab(_RunsPageTab.values[index]);
|
|
176
|
-
if (widget.controller.selectedSection != section) {
|
|
177
|
-
widget.controller.setSelectedSection(section);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
_RunsPageTab _tabForSection(AppSection section) {
|
|
182
|
-
switch (section) {
|
|
183
|
-
case AppSection.logs:
|
|
184
|
-
return _RunsPageTab.logs;
|
|
185
|
-
default:
|
|
186
|
-
return _RunsPageTab.runs;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
AppSection _sectionForTab(_RunsPageTab tab) {
|
|
191
|
-
switch (tab) {
|
|
192
|
-
case _RunsPageTab.logs:
|
|
193
|
-
return AppSection.logs;
|
|
194
|
-
case _RunsPageTab.runs:
|
|
195
|
-
return AppSection.runs;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
129
|
+
class _RunsAndLogsPanelState extends State<RunsAndLogsPanel> {
|
|
199
130
|
@override
|
|
200
131
|
Widget build(BuildContext context) {
|
|
201
132
|
final controller = widget.controller;
|
|
@@ -204,38 +135,12 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
204
135
|
child: Column(
|
|
205
136
|
children: <Widget>[
|
|
206
137
|
const _PageTitle(
|
|
207
|
-
title: 'Runs
|
|
208
|
-
subtitle:
|
|
209
|
-
'Inspect execution history, failures, tool traces, and diagnostics from one workspace.',
|
|
210
|
-
),
|
|
211
|
-
const SizedBox(height: 12),
|
|
212
|
-
Container(
|
|
213
|
-
decoration: BoxDecoration(
|
|
214
|
-
color: _bgSecondary,
|
|
215
|
-
borderRadius: BorderRadius.circular(14),
|
|
216
|
-
border: Border.all(color: _border),
|
|
217
|
-
),
|
|
218
|
-
child: TabBar(
|
|
219
|
-
controller: _tabController,
|
|
220
|
-
dividerColor: _border,
|
|
221
|
-
indicatorSize: TabBarIndicatorSize.tab,
|
|
222
|
-
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
|
|
223
|
-
onTap: _selectSectionForTabIndex,
|
|
224
|
-
tabs: <Widget>[
|
|
225
|
-
Tab(text: 'Runs (${controller.recentRuns.length})'),
|
|
226
|
-
Tab(text: 'Logs (${controller.logs.length})'),
|
|
227
|
-
],
|
|
228
|
-
),
|
|
138
|
+
title: 'Runs',
|
|
139
|
+
subtitle: 'Inspect execution history, failures, and tool traces.',
|
|
229
140
|
),
|
|
230
141
|
const SizedBox(height: 12),
|
|
231
142
|
Expanded(
|
|
232
|
-
child:
|
|
233
|
-
controller: _tabController,
|
|
234
|
-
children: <Widget>[
|
|
235
|
-
RunsPanel(controller: controller, embedded: true),
|
|
236
|
-
LogsPanel(controller: controller, embedded: true),
|
|
237
|
-
],
|
|
238
|
-
),
|
|
143
|
+
child: RunsPanel(controller: controller, embedded: true),
|
|
239
144
|
),
|
|
240
145
|
],
|
|
241
146
|
),
|
package/lib/manager.js
CHANGED
|
@@ -222,6 +222,14 @@ function removeEnvValue(key) {
|
|
|
222
222
|
return true;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
function readAdminCredentials() {
|
|
226
|
+
const env = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
|
|
227
|
+
return {
|
|
228
|
+
username: env.ADMIN_USERNAME || 'admin',
|
|
229
|
+
password: env.ADMIN_PASSWORD || '(not set — run `neoagent setup`)',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
225
233
|
function maskEnvValue(key, value) {
|
|
226
234
|
if (!/(KEY|TOKEN|SECRET|PASSWORD)/i.test(key)) return value;
|
|
227
235
|
const text = String(value || '');
|
|
@@ -768,6 +776,11 @@ async function cmdSetup() {
|
|
|
768
776
|
origins ? `ALLOWED_ORIGINS=${origins}` : ''
|
|
769
777
|
].filter(Boolean);
|
|
770
778
|
|
|
779
|
+
const adminUsername = current.ADMIN_USERNAME || 'admin';
|
|
780
|
+
const adminPassword = current.ADMIN_PASSWORD || crypto.randomBytes(16).toString('hex');
|
|
781
|
+
lines.push(`ADMIN_USERNAME=${adminUsername}`);
|
|
782
|
+
lines.push(`ADMIN_PASSWORD=${adminPassword}`);
|
|
783
|
+
|
|
771
784
|
fs.writeFileSync(ENV_FILE, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
772
785
|
logOk(`Wrote ${ENV_FILE}`);
|
|
773
786
|
}
|
|
@@ -1590,6 +1603,12 @@ async function cmdInstall() {
|
|
|
1590
1603
|
printInstallActionItems();
|
|
1591
1604
|
heading('Ready');
|
|
1592
1605
|
logInfo(`Open http://localhost:${port} or run \`neoagent status\` for a health check.`);
|
|
1606
|
+
const adminCreds = readAdminCredentials();
|
|
1607
|
+
heading('Admin Dashboard');
|
|
1608
|
+
logOk(`URL: http://localhost:${port}/admin`);
|
|
1609
|
+
logInfo(`Username: ${adminCreds.username}`);
|
|
1610
|
+
logInfo(`Password: ${adminCreds.password}`);
|
|
1611
|
+
logWarn('Save these credentials — stored in .env and shown again with `neoagent admin`');
|
|
1593
1612
|
}
|
|
1594
1613
|
|
|
1595
1614
|
function cmdStart() {
|
|
@@ -1965,6 +1984,10 @@ function printHelp() {
|
|
|
1965
1984
|
row('login grok-oauth', 'Authenticate Grok (xAI OAuth)');
|
|
1966
1985
|
console.log('');
|
|
1967
1986
|
|
|
1987
|
+
cliSection('Admin');
|
|
1988
|
+
row('admin', 'Show admin dashboard URL and credentials');
|
|
1989
|
+
console.log('');
|
|
1990
|
+
|
|
1968
1991
|
cliSection('Maintenance');
|
|
1969
1992
|
row('migrate', 'Migrate from another agent installation');
|
|
1970
1993
|
row('migrate dry-run', 'Preview what would be migrated');
|
|
@@ -2021,6 +2044,15 @@ async function runCLI(argv) {
|
|
|
2021
2044
|
case 'login':
|
|
2022
2045
|
await cmdLogin(argv.slice(1));
|
|
2023
2046
|
break;
|
|
2047
|
+
case 'admin': {
|
|
2048
|
+
cliBanner('Admin Dashboard', 'credentials');
|
|
2049
|
+
const adminCreds = readAdminCredentials();
|
|
2050
|
+
const port = loadEnvPort();
|
|
2051
|
+
logOk(`URL: http://localhost:${port}/admin`);
|
|
2052
|
+
logInfo(`Username: ${adminCreds.username}`);
|
|
2053
|
+
logInfo(`Password: ${adminCreds.password}`);
|
|
2054
|
+
break;
|
|
2055
|
+
}
|
|
2024
2056
|
case 'version':
|
|
2025
2057
|
case '--version':
|
|
2026
2058
|
case '-V':
|
package/package.json
CHANGED
package/runtime/paths.js
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── Access Settings ────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
let _revealedKey = null; // holds the API key shown after rotation (one-time)
|
|
6
|
+
let _revealTimer = null;
|
|
7
|
+
|
|
8
|
+
async function loadAccess() {
|
|
9
|
+
const el = document.getElementById('access-content');
|
|
10
|
+
if (!el) return;
|
|
11
|
+
try {
|
|
12
|
+
const data = await api('/admin/api/settings').then((r) => r.json());
|
|
13
|
+
renderAccess(el, data);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err.message !== 'unauthorized') el.innerHTML = '<div class="empty">Failed to load settings</div>';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderAccess(el, { signupEnabled, apiKeyConfigured, apiKeyHint }) {
|
|
20
|
+
el.innerHTML = `
|
|
21
|
+
<!-- Signup -->
|
|
22
|
+
<div class="card">
|
|
23
|
+
<div class="card-title">User Signups</div>
|
|
24
|
+
<div class="access-row">
|
|
25
|
+
<div>
|
|
26
|
+
<div class="access-label">Allow new registrations</div>
|
|
27
|
+
<div class="access-desc">
|
|
28
|
+
When disabled, only existing users can log in. The first account can always be created regardless of this setting.
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<label class="toggle" aria-label="Toggle signups">
|
|
32
|
+
<input type="checkbox" id="signup-toggle" ${signupEnabled ? 'checked' : ''}
|
|
33
|
+
onchange="setSignup(this.checked)">
|
|
34
|
+
<span class="toggle-track"></span>
|
|
35
|
+
<span class="toggle-thumb"></span>
|
|
36
|
+
</label>
|
|
37
|
+
</div>
|
|
38
|
+
<div id="signup-status" class="access-status"></div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- API key -->
|
|
42
|
+
<div class="card">
|
|
43
|
+
<div class="card-title">Admin API Key</div>
|
|
44
|
+
<div class="access-row">
|
|
45
|
+
<div>
|
|
46
|
+
<div class="access-label">Programmatic access</div>
|
|
47
|
+
<div class="access-desc">
|
|
48
|
+
Use <code>Authorization: Bearer <key></code> to authenticate against any <code>/admin/api/*</code> endpoint from scripts or automation — without a browser session.
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<span class="badge ${apiKeyConfigured ? 'badge-ok' : 'badge-idle'}">
|
|
52
|
+
${apiKeyConfigured ? 'configured' : 'not set'}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
${apiKeyConfigured ? `
|
|
57
|
+
<div class="access-hint-row">
|
|
58
|
+
<code class="api-key-hint">${esc(apiKeyHint)}</code>
|
|
59
|
+
<span style="font-size:11px;color:var(--text-muted);">stored in .env — rotate to reveal</span>
|
|
60
|
+
</div>` : ''}
|
|
61
|
+
|
|
62
|
+
<div id="key-reveal-wrap" style="display:none;">
|
|
63
|
+
<div class="key-reveal-box">
|
|
64
|
+
<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">
|
|
65
|
+
New API key — copy it now. It will not be shown again.
|
|
66
|
+
</div>
|
|
67
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
68
|
+
<code id="key-reveal-value" class="api-key-full"></code>
|
|
69
|
+
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;flex-shrink:0;"
|
|
70
|
+
onclick="copyApiKey()">Copy</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div style="margin-top:8px;font-size:11px;color:var(--text-muted);" id="key-reveal-timer"></div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="access-actions">
|
|
77
|
+
<button class="btn btn-ghost" onclick="rotateApiKey(this)">
|
|
78
|
+
${apiKeyConfigured ? 'Rotate key' : 'Generate key'}
|
|
79
|
+
</button>
|
|
80
|
+
${apiKeyConfigured ? `
|
|
81
|
+
<button class="btn btn-danger" onclick="revokeApiKey(this)">Revoke key</button>` : ''}
|
|
82
|
+
</div>
|
|
83
|
+
<div id="apikey-status" class="access-status"></div>
|
|
84
|
+
</div>`;
|
|
85
|
+
|
|
86
|
+
// If we rotated in this page load, re-show the key
|
|
87
|
+
if (_revealedKey) showReveal(_revealedKey);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function setSignup(enabled) {
|
|
91
|
+
const statusEl = document.getElementById('signup-status');
|
|
92
|
+
if (statusEl) statusEl.textContent = '';
|
|
93
|
+
try {
|
|
94
|
+
const res = await api('/admin/api/settings/signup', {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ enabled }),
|
|
98
|
+
});
|
|
99
|
+
if (res.ok) {
|
|
100
|
+
showStatus('signup-status', enabled ? 'Signups enabled.' : 'Signups disabled.', false);
|
|
101
|
+
} else {
|
|
102
|
+
const b = await res.json().catch(() => ({}));
|
|
103
|
+
showStatus('signup-status', b.error || 'Failed to update', true);
|
|
104
|
+
// Revert the toggle
|
|
105
|
+
const toggle = document.getElementById('signup-toggle');
|
|
106
|
+
if (toggle) toggle.checked = !enabled;
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.message !== 'unauthorized') showStatus('signup-status', 'Network error', true);
|
|
110
|
+
const toggle = document.getElementById('signup-toggle');
|
|
111
|
+
if (toggle) toggle.checked = !enabled;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function rotateApiKey(btn) {
|
|
116
|
+
if (!confirm(
|
|
117
|
+
'Rotate admin API key?\n\n' +
|
|
118
|
+
'The current key will stop working immediately.\n' +
|
|
119
|
+
'Any scripts using it must be updated.\n\n' +
|
|
120
|
+
'The new key will be shown once — copy it before leaving.'
|
|
121
|
+
)) return;
|
|
122
|
+
btn.disabled = true;
|
|
123
|
+
btn.textContent = 'Rotating…';
|
|
124
|
+
try {
|
|
125
|
+
const res = await api('/admin/api/settings/apikey/rotate', { method: 'POST' });
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
if (res.ok && data.apiKey) {
|
|
128
|
+
_revealedKey = data.apiKey;
|
|
129
|
+
await loadAccess(); // re-render to show badge + hint
|
|
130
|
+
} else {
|
|
131
|
+
showStatus('apikey-status', data.error || 'Failed to rotate', true);
|
|
132
|
+
btn.disabled = false;
|
|
133
|
+
btn.textContent = 'Rotate key';
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.message !== 'unauthorized') showStatus('apikey-status', 'Network error', true);
|
|
137
|
+
btn.disabled = false;
|
|
138
|
+
btn.textContent = 'Rotate key';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function revokeApiKey(btn) {
|
|
143
|
+
if (!confirm('Revoke admin API key?\n\nAll programmatic access using this key will stop immediately.')) return;
|
|
144
|
+
btn.disabled = true;
|
|
145
|
+
btn.textContent = 'Revoking…';
|
|
146
|
+
_revealedKey = null;
|
|
147
|
+
try {
|
|
148
|
+
const res = await api('/admin/api/settings/apikey', { method: 'DELETE' });
|
|
149
|
+
if (res.ok) {
|
|
150
|
+
await loadAccess();
|
|
151
|
+
} else {
|
|
152
|
+
const b = await res.json().catch(() => ({}));
|
|
153
|
+
showStatus('apikey-status', b.error || 'Failed', true);
|
|
154
|
+
btn.disabled = false;
|
|
155
|
+
btn.textContent = 'Revoke key';
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.message !== 'unauthorized') showStatus('apikey-status', 'Network error', true);
|
|
159
|
+
btn.disabled = false;
|
|
160
|
+
btn.textContent = 'Revoke key';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function copyApiKey() {
|
|
165
|
+
if (_revealedKey) navigator.clipboard.writeText(_revealedKey).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function showReveal(key) {
|
|
169
|
+
const wrap = document.getElementById('key-reveal-wrap');
|
|
170
|
+
const value = document.getElementById('key-reveal-value');
|
|
171
|
+
if (!wrap || !value) return;
|
|
172
|
+
value.textContent = key;
|
|
173
|
+
wrap.style.display = 'block';
|
|
174
|
+
// Auto-clear after 120 seconds
|
|
175
|
+
clearTimeout(_revealTimer);
|
|
176
|
+
let secs = 120;
|
|
177
|
+
const timerEl = document.getElementById('key-reveal-timer');
|
|
178
|
+
const tick = () => {
|
|
179
|
+
if (!timerEl) return;
|
|
180
|
+
if (secs <= 0) {
|
|
181
|
+
wrap.style.display = 'none';
|
|
182
|
+
_revealedKey = null;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
timerEl.textContent = `Hides in ${secs}s`;
|
|
186
|
+
secs--;
|
|
187
|
+
_revealTimer = setTimeout(tick, 1000);
|
|
188
|
+
};
|
|
189
|
+
tick();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function showStatus(id, msg, isError) {
|
|
193
|
+
const el = document.getElementById(id);
|
|
194
|
+
if (!el) return;
|
|
195
|
+
el.textContent = msg;
|
|
196
|
+
el.style.color = isError ? 'var(--danger)' : 'var(--success)';
|
|
197
|
+
setTimeout(() => { if (el) el.textContent = ''; }, 4000);
|
|
198
|
+
}
|