proto(woo): blocking setup gate; require X-Woo-* on Woo HTTP routes
Full-screen WooSetupGate on App mount when this browser lacks device WP user + app password. Save + Test Woo dismisses only after a successful test. Publish/test-woo auth failures reopen the gate. Server: client_for_request requires X-Woo-* on publish, taxonomy sync, and test-woo (401 woo_device_creds_required); health probe keeps Settings. Optional SLAB_WOO_ALLOW_SETTINGS_FALLBACK for legacy tests. See PROTOTYPE.md.
This commit is contained in:
+18
-2
@@ -11,12 +11,15 @@
|
||||
import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import WooSetupGate from './create/settings/WooSetupGate.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
import {
|
||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||
} from './create/flow';
|
||||
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
|
||||
import { createSlab, uploadPhoto, ApiError } from './api/slabs';
|
||||
import { hasDeviceWooCreds } from './api/wooDeviceCreds';
|
||||
import { wooGateForcedOpen, clearWooGateForce } from './api/wooGate';
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
@@ -26,9 +29,12 @@
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
// Blocking Woo setup until this browser has device creds (or reopen after 401).
|
||||
let showWooGate = typeof window !== 'undefined' ? !hasDeviceWooCreds() : true;
|
||||
|
||||
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||
$: curFlow = $flow;
|
||||
$: if ($wooGateForcedOpen) showWooGate = true;
|
||||
let saving = false;
|
||||
let saveError = '';
|
||||
|
||||
@@ -38,7 +44,13 @@
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||
|
||||
function onWooGateReady(): void {
|
||||
clearWooGateForce();
|
||||
showWooGate = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
showWooGate = !hasDeviceWooCreds() || $wooGateForcedOpen;
|
||||
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
||||
// device draft into IndexedDB after the origin exists, then reload without
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
@@ -146,7 +158,10 @@
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if showWooGate}
|
||||
<WooSetupGate onReady={onWooGateReady} />
|
||||
{/if}
|
||||
<main class="app" class:gated={showWooGate} aria-hidden={showWooGate ? 'true' : undefined}>
|
||||
{#if curFlow.phase === 'review'}
|
||||
<Review
|
||||
slabId={curFlow.slabId}
|
||||
@@ -256,6 +271,7 @@
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
|
||||
.app { padding: 12px 16px 24px; }
|
||||
.app.gated { visibility: hidden; pointer-events: none; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// publish.py POST /api/v1/slabs/{id}/publish
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
||||
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage when set.
|
||||
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage (required on those routes).
|
||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||
//
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import {
|
||||
clearDeviceWooCreds,
|
||||
getDeviceWooCreds,
|
||||
hasDeviceWooCreds,
|
||||
setDeviceWooCreds,
|
||||
} from './wooDeviceCreds';
|
||||
|
||||
const mem = new Map<string, string>();
|
||||
|
||||
const fakeLS = {
|
||||
getItem(k: string) {
|
||||
return mem.has(k) ? mem.get(k)! : null;
|
||||
},
|
||||
setItem(k: string, v: string) {
|
||||
mem.set(k, String(v));
|
||||
},
|
||||
removeItem(k: string) {
|
||||
mem.delete(k);
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mem.clear();
|
||||
});
|
||||
|
||||
test('hasDeviceWooCreds is false until both fields are set', () => {
|
||||
(globalThis as any).localStorage = fakeLS;
|
||||
assert.equal(hasDeviceWooCreds(), false);
|
||||
setDeviceWooCreds({ username: 'alice', appPassword: '' });
|
||||
assert.equal(hasDeviceWooCreds(), false);
|
||||
setDeviceWooCreds({ username: 'alice', appPassword: 'pw' });
|
||||
assert.equal(hasDeviceWooCreds(), true);
|
||||
assert.deepEqual(getDeviceWooCreds(), { username: 'alice', appPassword: 'pw' });
|
||||
clearDeviceWooCreds();
|
||||
assert.equal(hasDeviceWooCreds(), false);
|
||||
});
|
||||
@@ -76,3 +76,9 @@ export function withWooHeaders(extra?: HeadersInit): HeadersInit {
|
||||
}
|
||||
return { ...extra, ...woo };
|
||||
}
|
||||
|
||||
/** True when this browser has both device Woo username and app password. */
|
||||
export function hasDeviceWooCreds(): boolean {
|
||||
const { username, appPassword } = getDeviceWooCreds();
|
||||
return Boolean(username && appPassword);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Blocking Woo setup gate visibility.
|
||||
* App shows the overlay when device creds are missing or this store is forced open
|
||||
* (e.g. publish/test-woo returned woo_auth / woo_device_creds_required).
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const wooGateForcedOpen = writable(false);
|
||||
|
||||
export function reopenWooGate(): void {
|
||||
wooGateForcedOpen.set(true);
|
||||
}
|
||||
|
||||
export function clearWooGateForce(): void {
|
||||
wooGateForcedOpen.set(false);
|
||||
}
|
||||
|
||||
/** True for ApiError shapes that mean "device Woo login needed again". */
|
||||
export function isWooAuthFailure(status: number, code: string | null): boolean {
|
||||
if (status === 401) return true;
|
||||
return code === 'woo_auth' || code === 'woo_device_creds_required';
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
|
||||
type Slab, type Taxonomy, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
|
||||
import PublishPreview from './PublishPreview.svelte';
|
||||
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
|
||||
|
||||
@@ -58,6 +59,8 @@
|
||||
.catch((e) => {
|
||||
if (e instanceof ApiError) {
|
||||
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
|
||||
// Device Woo rejected or missing → reopen blocking setup gate.
|
||||
if (isWooAuthFailure(e.status, e.code)) reopenWooGate();
|
||||
} else {
|
||||
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
clearDeviceWooCreds,
|
||||
withWooHeaders,
|
||||
} from '../../api/wooDeviceCreds';
|
||||
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
|
||||
|
||||
interface SecretState { configured: boolean }
|
||||
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
||||
@@ -272,13 +273,13 @@
|
||||
deviceWoo = getDeviceWooCreds();
|
||||
deviceMsg = deviceWoo.username && deviceWoo.appPassword
|
||||
? 'Saved on this device (localStorage). Not written to server Settings.'
|
||||
: 'Cleared — Woo calls will use shop-default Settings credentials.';
|
||||
: 'Cleared — publish / Test Woo need device credentials again.';
|
||||
}
|
||||
|
||||
function clearDeviceWoo() {
|
||||
clearDeviceWooCreds();
|
||||
deviceWoo = { username: '', appPassword: '' };
|
||||
deviceMsg = 'Cleared — Woo calls will use shop-default Settings credentials.';
|
||||
deviceMsg = 'Cleared — publish / Test Woo need device credentials again.';
|
||||
}
|
||||
|
||||
async function testWoo() {
|
||||
@@ -288,7 +289,18 @@
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
});
|
||||
const body = await res.json();
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (isWooAuthFailure(res.status, body?.error?.code ?? null) || body?.ok === false) {
|
||||
const detail =
|
||||
body?.error?.message ||
|
||||
body?.detail ||
|
||||
(body?.ok === false ? 'failed' : 'Device Woo credentials required');
|
||||
wooTest = { ok: false, detail };
|
||||
if (isWooAuthFailure(res.status, body?.error?.code ?? null) || /auth rejected/i.test(detail)) {
|
||||
reopenWooGate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
||||
if (wooTest.ok) await loadRates();
|
||||
} catch {
|
||||
@@ -325,11 +337,17 @@
|
||||
<div class="section" data-testid="device-woo-creds">
|
||||
<h3>This device’s Woo credentials</h3>
|
||||
<p class="hint">
|
||||
Per-browser WP user + application password (localStorage). Sent as
|
||||
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code> on publish,
|
||||
taxonomy sync, and Test Woo — never written into shared server Settings.
|
||||
Leave blank to use the shop-default below.
|
||||
Per-browser WP user + application password (localStorage). Required on
|
||||
publish, taxonomy sync, and Test Woo (sent as
|
||||
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code>) — never
|
||||
written into shared server Settings. A first-run gate collects these if
|
||||
this browser has none yet.
|
||||
</p>
|
||||
{#if !(deviceWoo.username && deviceWoo.appPassword)}
|
||||
<p class="msg err" data-testid="device-woo-reminder">
|
||||
This device has no Woo credentials yet. Save them here or reload to open the setup gate.
|
||||
</p>
|
||||
{/if}
|
||||
<div>
|
||||
<label>Device WP Username</label>
|
||||
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
|
||||
@@ -354,9 +372,8 @@
|
||||
<div class="section">
|
||||
<h3>Shop-default Woo (server Settings)</h3>
|
||||
<p class="hint">
|
||||
Shared fallback for this install when this device has no local credentials.
|
||||
Saving here updates the encrypted Settings row — do not put other users’
|
||||
passwords here.
|
||||
Shop-default row (health probe / legacy). Publish and Test Woo use this
|
||||
device’s credentials only — do not put other users’ passwords here.
|
||||
</p>
|
||||
<div>
|
||||
<label>Shop-default WP Username</label>
|
||||
@@ -415,7 +432,7 @@
|
||||
|
||||
<div class="section">
|
||||
<h3>WooCommerce</h3>
|
||||
<p class="hint">Uses this device’s Woo credentials when set; otherwise shop-default Settings.</p>
|
||||
<p class="hint">Uses this device’s Woo credentials (required). Shop-default Settings are not used on Test Woo.</p>
|
||||
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
|
||||
{#if wooTest}
|
||||
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getDeviceWooCreds,
|
||||
setDeviceWooCreds,
|
||||
withWooHeaders,
|
||||
} from '../../api/wooDeviceCreds';
|
||||
import { getSettings, updateSettings, type SettingsView } from '../../api/slabs';
|
||||
|
||||
/** Called only after Save + Test Woo succeeds. */
|
||||
export let onReady: () => void;
|
||||
|
||||
type Secret = { configured: boolean } | string;
|
||||
const isUnmasked = (s: Secret) => typeof s === 'string';
|
||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
||||
const secretConfigured = (s: Secret | undefined | null): boolean => {
|
||||
if (s == null) return false;
|
||||
return isUnmasked(s) ? (s as string).length > 0 : !!(s as { configured: boolean }).configured;
|
||||
};
|
||||
const inferenceUrlConfigured = (): boolean =>
|
||||
settings ? secretConfigured(settings.inference_base_url as Secret) : false;
|
||||
const inferenceKeyConfigured = (): boolean =>
|
||||
settings ? secretConfigured(settings.inference_api_key as Secret) : false;
|
||||
const inferenceUrlMaskedKeep = (): boolean =>
|
||||
!!(settings && !isUnmasked(settings.inference_base_url as Secret) && inferenceUrlConfigured());
|
||||
const inferenceKeyMaskedKeep = (): boolean =>
|
||||
!!(settings && !isUnmasked(settings.inference_api_key as Secret) && inferenceKeyConfigured());
|
||||
|
||||
let loading = true;
|
||||
let loadError = '';
|
||||
let settings: SettingsView | null = null;
|
||||
|
||||
let username = '';
|
||||
let appPassword = '';
|
||||
let inferenceUrl = '';
|
||||
let inferenceKey = '';
|
||||
|
||||
let busy = false;
|
||||
let status = '';
|
||||
let statusOk = false;
|
||||
|
||||
onMount(() => {
|
||||
const existing = getDeviceWooCreds();
|
||||
username = existing.username;
|
||||
appPassword = existing.appPassword;
|
||||
void loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
settings = await getSettings();
|
||||
// Prefill inference only when unmasked (UAT); otherwise leave blank write-only.
|
||||
inferenceUrl = secretValue(settings.inference_base_url as Secret);
|
||||
inferenceKey = secretValue(settings.inference_api_key as Secret);
|
||||
} catch {
|
||||
loadError = 'Could not load settings. Check the connection and try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndTest() {
|
||||
const user = username.trim();
|
||||
const pass = appPassword.trim();
|
||||
if (!user || !pass) {
|
||||
status = 'Enter this device’s WordPress user and application password.';
|
||||
statusOk = false;
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = 'Saving…';
|
||||
statusOk = false;
|
||||
try {
|
||||
// Inference only — never write device Woo into Settings.
|
||||
const inferPatch: Record<string, string> = {};
|
||||
if (inferenceUrl.trim()) inferPatch.inference_base_url = inferenceUrl.trim();
|
||||
if (inferenceKey.trim()) inferPatch.inference_api_key = inferenceKey.trim();
|
||||
if (Object.keys(inferPatch).length) {
|
||||
settings = await updateSettings(inferPatch);
|
||||
inferenceUrl = secretValue(settings.inference_base_url as Secret);
|
||||
inferenceKey = secretValue(settings.inference_api_key as Secret);
|
||||
}
|
||||
|
||||
setDeviceWooCreds({ username: user, appPassword: pass });
|
||||
username = user;
|
||||
appPassword = pass;
|
||||
|
||||
status = 'Testing WooCommerce…';
|
||||
const res = await fetch('/api/v1/settings/test-woo', {
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
});
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (res.status === 401 || (body && body.error?.code === 'woo_device_creds_required')) {
|
||||
status = body?.error?.message || 'Device Woo credentials required.';
|
||||
statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
status = body?.error?.message || body?.detail || `Test failed (${res.status})`;
|
||||
statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (body && body.ok) {
|
||||
status = body.detail || 'Connected & synced';
|
||||
statusOk = true;
|
||||
onReady();
|
||||
return;
|
||||
}
|
||||
status = (body && body.detail) || 'Store rejected the credentials. Check user and app password.';
|
||||
statusOk = false;
|
||||
} catch {
|
||||
status = 'Network error — try again.';
|
||||
statusOk = false;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="gate" role="dialog" aria-modal="true" aria-labelledby="woo-gate-title" data-testid="woo-setup-gate">
|
||||
<div class="card">
|
||||
<h1 id="woo-gate-title">Set up WooCommerce for this device</h1>
|
||||
<p class="lede">
|
||||
Each browser uses its own WordPress user and application password. They stay on this
|
||||
device only — not in shared server Settings. Inference stays shared for the shop.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="busy">Loading…</p>
|
||||
{:else}
|
||||
{#if loadError}
|
||||
<p class="err" role="alert">{loadError}</p>
|
||||
<button type="button" class="ghost" on:click={() => void loadSettings()}>Retry</button>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="woo-base">Woo store URL</label>
|
||||
<input id="woo-base" type="text" value={settings?.woo_base_url || ''} disabled data-testid="woo-gate-base-url" />
|
||||
<p class="hint">From server env — display only.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="woo-user">Device WordPress username</label>
|
||||
<input
|
||||
id="woo-user"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
data-testid="woo-gate-username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="woo-pass">Device application password</label>
|
||||
<input
|
||||
id="woo-pass"
|
||||
type="password"
|
||||
bind:value={appPassword}
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
data-testid="woo-gate-app-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Inference (shared)</h2>
|
||||
<div class="field">
|
||||
<label for="inf-url">
|
||||
Inference base URL
|
||||
{#if inferenceUrlConfigured()}
|
||||
<span class="tag">configured</span>
|
||||
{:else}
|
||||
<span class="tag miss">not set</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
id="inf-url"
|
||||
type="text"
|
||||
bind:value={inferenceUrl}
|
||||
placeholder={inferenceUrlMaskedKeep() ? '•••••••• (leave blank to keep)' : 'https://…'}
|
||||
data-testid="woo-gate-inference-url"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inf-key">
|
||||
Inference API key
|
||||
{#if inferenceKeyConfigured()}
|
||||
<span class="tag">configured</span>
|
||||
{:else}
|
||||
<span class="tag miss">not set</span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
id="inf-key"
|
||||
type="password"
|
||||
bind:value={inferenceKey}
|
||||
placeholder={inferenceKeyMaskedKeep() ? '•••••••• (leave blank to keep)' : ''}
|
||||
data-testid="woo-gate-inference-key"
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">Only filled values are saved to shared Settings. Device Woo is never written there.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={busy || !username.trim() || !appPassword.trim()}
|
||||
data-testid="woo-gate-save-test"
|
||||
on:click={() => void saveAndTest()}
|
||||
>
|
||||
{busy ? 'Working…' : 'Save device creds & Test Woo'}
|
||||
</button>
|
||||
{#if status}
|
||||
<p class="msg" class:ok={statusOk} class:err={!statusOk} role="status" data-testid="woo-gate-status">{status}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(8, 8, 8, 0.92);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 20px 18px 24px;
|
||||
color: #eee;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
h1 { font-size: 1.35rem; margin: 0 0 8px; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 10px; color: #ddd; }
|
||||
.lede { color: #aaa; font-size: 0.9rem; margin: 0 0 18px; line-height: 1.4; }
|
||||
.section {
|
||||
margin: 18px 0 8px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
.field { margin-bottom: 12px; }
|
||||
label { display: block; margin: 0 0 4px; font-size: 0.9rem; color: #ccc; }
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #444;
|
||||
background: #121212;
|
||||
color: #eee;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input:disabled { color: #888; background: #1c1c1c; }
|
||||
.hint { margin: 4px 0 0; color: #888; font-size: 0.8rem; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
background: #2a3a2a;
|
||||
color: #7fd37f;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag.miss { background: #3a2a2a; color: #e8a; }
|
||||
.primary {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
background: #00e5ff;
|
||||
color: #002;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
|
||||
.ghost {
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #444;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg { margin-top: 12px; font-weight: 600; }
|
||||
.msg.ok { color: #7fd37f; }
|
||||
.msg.err, .err { color: #ffb4a2; }
|
||||
.busy { color: #aaa; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user