Fix first-run setup 401 error by adding bootstrap token unlock screen (related to #639)
After the security hardening that introduced bootstrap token protection, the first-run setup flow was broken because FirstRunSetup.tsx didn't prompt users for the token. This caused a 401 "Bootstrap setup token required" error during initial admin account creation. Changes: - Add dedicated unlock screen before the setup wizard - Display instructions for retrieving token from host - Include bootstrap token in quick-setup API request headers and body - Only require unlock for first-run setup (skip in force mode) The unlock screen follows the documented flow in README.md and ensures only users with host access can configure an unconfigured instance. Related to #639
This commit is contained in:
parent
20099549c6
commit
47748230f4
1 changed files with 93 additions and 2 deletions
|
|
@ -22,6 +22,9 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
|
||||||
const [savedToken, setSavedToken] = createSignal('');
|
const [savedToken, setSavedToken] = createSignal('');
|
||||||
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
|
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
|
||||||
const [themeMode, setThemeMode] = createSignal<'system' | 'light' | 'dark'>('system');
|
const [themeMode, setThemeMode] = createSignal<'system' | 'light' | 'dark'>('system');
|
||||||
|
const [bootstrapToken, setBootstrapToken] = createSignal('');
|
||||||
|
const [isUnlocking, setIsUnlocking] = createSignal(false);
|
||||||
|
const [isUnlocked, setIsUnlocked] = createSignal(false);
|
||||||
|
|
||||||
const applyTheme = (mode: 'system' | 'light' | 'dark') => {
|
const applyTheme = (mode: 'system' | 'light' | 'dark') => {
|
||||||
if (mode === 'light') {
|
if (mode === 'light') {
|
||||||
|
|
@ -77,6 +80,15 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
|
||||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnlock = () => {
|
||||||
|
if (!bootstrapToken().trim()) {
|
||||||
|
showError('Please enter the bootstrap token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Simple client-side unlock - actual validation happens during setup
|
||||||
|
setIsUnlocked(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetup = async () => {
|
const handleSetup = async () => {
|
||||||
// Validate custom password if used
|
// Validate custom password if used
|
||||||
if (useCustomPassword()) {
|
if (useCustomPassword()) {
|
||||||
|
|
@ -104,15 +116,23 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
|
||||||
setApiClientToken(token);
|
setApiClientToken(token);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
// Include bootstrap token if we're in first-run setup (not force mode)
|
||||||
|
if (!props.force && bootstrapToken()) {
|
||||||
|
headers['X-Setup-Token'] = bootstrapToken().trim();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/security/quick-setup', {
|
const response = await fetch('/api/security/quick-setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
credentials: 'include', // Include cookies for CSRF
|
credentials: 'include', // Include cookies for CSRF
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username(),
|
username: username(),
|
||||||
password: finalPassword,
|
password: finalPassword,
|
||||||
apiToken: token,
|
apiToken: token,
|
||||||
force: props.force ?? false,
|
force: props.force ?? false,
|
||||||
|
setupToken: bootstrapToken().trim(), // Also include in body as fallback
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -251,7 +271,78 @@ IMPORTANT: Keep these credentials secure!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
|
||||||
<Show when={!showCredentials()}>
|
{/* Bootstrap Token Unlock Screen */}
|
||||||
|
<Show when={!isUnlocked() && !showCredentials() && !props.force}>
|
||||||
|
<div class="p-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="Unlock Setup Wizard"
|
||||||
|
size="lg"
|
||||||
|
class="mb-6"
|
||||||
|
titleClass="text-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{/* Instructions */}
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-900 dark:text-blue-100 font-medium mb-2">
|
||||||
|
To begin setup, retrieve the bootstrap token from your Pulse host:
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded p-3 font-mono text-xs text-gray-800 dark:text-gray-200">
|
||||||
|
<div class="text-blue-600 dark:text-blue-400 mb-1"># Standard installation:</div>
|
||||||
|
cat /etc/pulse/.bootstrap_token
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded p-3 font-mono text-xs text-gray-800 dark:text-gray-200">
|
||||||
|
<div class="text-blue-600 dark:text-blue-400 mb-1"># Docker/Helm:</div>
|
||||||
|
cat /data/.bootstrap_token
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token Input */}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Bootstrap Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bootstrapToken()}
|
||||||
|
onInput={(e) => setBootstrapToken(e.currentTarget.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||||
|
placeholder="Paste the token from your host"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
This one-time token ensures only someone with host access can configure Pulse
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-200">Why this step?</span>
|
||||||
|
<br />
|
||||||
|
The bootstrap token prevents unauthorized access to your unconfigured Pulse instance.
|
||||||
|
It's automatically removed after you complete the setup wizard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnlock}
|
||||||
|
disabled={isUnlocking() || !bootstrapToken().trim()}
|
||||||
|
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUnlocking() ? 'Unlocking...' : 'Unlock Wizard'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Setup Form - only shown after unlock or in force mode */}
|
||||||
|
<Show when={(isUnlocked() || props.force) && !showCredentials()}>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Initial security setup"
|
title="Initial security setup"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue