Merge branch 'master' into hans/fix-8
This commit is contained in:
commit
063d2c8cd8
|
|
@ -122,6 +122,37 @@ func (c *tc) reqNoAuth(method, path string, body any) *http.Response {
|
|||
return resp
|
||||
}
|
||||
|
||||
// reqAgent sends a request with CVT wire token authentication (agent).
|
||||
// The wireToken is a type 0x00 CVT token containing L1 + agent_id.
|
||||
func (c *tc) reqAgent(method, path string, body any, wireToken string) *http.Response {
|
||||
c.t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.srv.URL+path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+wireToken)
|
||||
resp, err := c.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatalf("reqAgent %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// mintWireToken creates a CVT wire token (type 0x00) for agent authentication.
|
||||
func (c *tc) mintWireToken(agentID []byte) string {
|
||||
c.t.Helper()
|
||||
token, err := lib.MintWireToken(fakeL0(), fakeL1(), agentID)
|
||||
if err != nil {
|
||||
c.t.Fatalf("mintWireToken: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// must asserts status code and returns parsed JSON object.
|
||||
func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
|
||||
c.t.Helper()
|
||||
|
|
@ -323,29 +354,195 @@ func TestAuditLog(t *testing.T) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) {
|
||||
// AGENT CREDENTIALS NOW GENERATED CLIENT-SIDE
|
||||
// Tests requiring agent tokens need client-side credential generation
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries with different scopes
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Work Entry", "user", "pass", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Personal Entry", "user", "pass", nil)), 201)
|
||||
|
||||
// Create agent with specific scope (work only)
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Work Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{"work"},
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with work scope should see entries (scope filtering happens at data level)
|
||||
// With empty scope_whitelist, agent sees nothing
|
||||
// With ["work"], agent sees work entries
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
// Agent with work scope should see entries (default scope is "0000" which is owner-only)
|
||||
// Since entries have no explicit scope, they default to "0000" (owner-only)
|
||||
// So agent should see 0 entries
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("agent with work scope should see 0 owner-scoped entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create an entry
|
||||
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Test Entry", "user", "pass", nil)), 201)
|
||||
entryID := entry["entry_id"].(string)
|
||||
|
||||
// Create agent with no scope whitelist (cannot access owner-only entries)
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Limited Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{}, // Empty whitelist
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to access owner-only entry
|
||||
resp := c.reqAgent("GET", "/api/entries/"+entryID, nil, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent without scope access should get 403, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_all_access_sees_everything(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user", "pass", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user", "pass", nil)), 201)
|
||||
|
||||
// Create agent with all_access flag
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "All Access Agent",
|
||||
"all_access": true,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with all_access should see all entries
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("all_access agent should see 2 entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Regular Agent",
|
||||
"all_access": false,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to create another agent
|
||||
resp := c.reqAgent("POST", "/api/agents", map[string]any{
|
||||
"name": "New Agent",
|
||||
"all_access": false,
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create agents, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Agent should get 403 when trying to list agents
|
||||
resp = c.reqAgent("GET", "/api/agents", nil, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to list agents, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) {
|
||||
// Agents cannot create entries with system types (agent, scope)
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Regular Agent",
|
||||
"all_access": false,
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent should get 403 when trying to create entry with type=agent
|
||||
resp := c.reqAgent("POST", "/api/entries", map[string]any{
|
||||
"title": "Fake Agent",
|
||||
"type": "agent",
|
||||
"data": map[string]any{
|
||||
"title": "Fake Agent",
|
||||
"type": "agent",
|
||||
},
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create agent-type entries, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Agent should get 403 when trying to create entry with type=scope
|
||||
resp = c.reqAgent("POST", "/api/entries", map[string]any{
|
||||
"title": "Fake Scope",
|
||||
"type": "scope",
|
||||
"data": map[string]any{
|
||||
"title": "Fake Scope",
|
||||
"type": "scope",
|
||||
},
|
||||
}, wireToken)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("agent should not be able to create scope-type entries, expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestScopedAccess_agent_entries_invisible(t *testing.T) {
|
||||
t.Skip("Agent credentials now client-side - test needs rewrite")
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create entries
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user1", "pass1", nil)), 201)
|
||||
c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user2", "pass2", nil)), 201)
|
||||
|
||||
// Create agent with limited scope
|
||||
agentResp := c.must(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Limited Agent",
|
||||
"all_access": false,
|
||||
"scope_whitelist": []string{"work"},
|
||||
}), 201)
|
||||
agentIDHex := agentResp["agent_id"].(string)
|
||||
agentID, _ := hex.DecodeString(agentIDHex)
|
||||
|
||||
// Mint wire token for agent authentication
|
||||
wireToken := c.mintWireToken(agentID)
|
||||
|
||||
// Agent with work scope should not see owner-only entries (default scope "0000")
|
||||
list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200)
|
||||
// Entries default to "0000" scope (owner-only), so agent sees nothing
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("agent with work scope should see 0 owner-only entries, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -424,9 +621,32 @@ func TestKeyLeak_L3_never_appears(t *testing.T) {
|
|||
|
||||
func TestKeyLeak_agent_credential_is_opaque(t *testing.T) {
|
||||
// CREDENTIAL GENERATION IS NOW CLIENT-SIDE
|
||||
// Server returns agent_id, web UI generates credential using crypto.js
|
||||
// This test is obsolete - credential opacity is ensured by client-side implementation
|
||||
t.Skip("Credential generation moved to client-side - server no longer returns credential")
|
||||
// Server returns agent_id only, web UI generates credential using crypto.js
|
||||
// This test verifies that the server response contains no key material
|
||||
c := newTestClient(t)
|
||||
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
||||
|
||||
// Create agent - server returns only agent_id, no credential
|
||||
body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{
|
||||
"name": "Leak Check Agent",
|
||||
}), 201)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify response contains agent_id but no credential token
|
||||
if !strings.Contains(bodyStr, "agent_id") {
|
||||
t.Fatal("agent creation response should contain agent_id")
|
||||
}
|
||||
|
||||
// Response should NOT contain cvt_ prefix (credential tokens start with cvt_)
|
||||
if strings.Contains(bodyStr, "cvt_") {
|
||||
t.Fatal("agent creation response should not contain cvt_ credential (now client-side)")
|
||||
}
|
||||
|
||||
// Verify no raw L2 in response
|
||||
l2Hex := hex.EncodeToString(fakeL2())
|
||||
if strings.Contains(bodyStr, l2Hex) {
|
||||
t.Fatal("L2 key (hex) found in agent creation response")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import (
|
|||
// (clavis-cli/src/cvt.c) and never decrypted by Go code — L2 is a hard veto
|
||||
// for the server.
|
||||
const (
|
||||
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
|
||||
CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16)
|
||||
CVTCredentialType byte = 0x01 // Client credential: L2(16) + agent_id(16) + POP(4)
|
||||
)
|
||||
|
||||
const cvtPrefix = "cvt_"
|
||||
|
|
@ -68,6 +69,36 @@ func ParseWireToken(token string) (l0, l1, agentID []byte, err error) {
|
|||
return l0, payload[0:8], payload[8:24], nil
|
||||
}
|
||||
|
||||
// MintCredential creates a type 0x01 client credential token (for testing).
|
||||
// This simulates client-side credential generation that normally happens in browser/CLI.
|
||||
// Payload: L2(16) + agent_id(16) + POP(4) = 36 bytes, encrypted with L0.
|
||||
func MintCredential(l0, l2, agentID, pop []byte) (string, error) {
|
||||
if len(l0) != 4 || len(l2) != 16 || len(agentID) != 16 || len(pop) != 4 {
|
||||
return "", fmt.Errorf("bad lengths: l0=%d l2=%d agent_id=%d pop=%d", len(l0), len(l2), len(agentID), len(pop))
|
||||
}
|
||||
payload := make([]byte, 36)
|
||||
copy(payload[0:16], l2)
|
||||
copy(payload[16:32], agentID)
|
||||
copy(payload[32:36], pop)
|
||||
return cvtEncode(CVTCredentialType, l0, payload)
|
||||
}
|
||||
|
||||
// ParseCredential decrypts a type 0x01 client credential token (for testing).
|
||||
// Returns L0 (4 bytes), L2 (16 bytes), agent_id (16 bytes), and POP (4 bytes).
|
||||
func ParseCredential(token string) (l0, l2, agentID, pop []byte, err error) {
|
||||
typ, l0, payload, err := cvtDecode(token)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
if typ != CVTCredentialType {
|
||||
return nil, nil, nil, nil, ErrCVTBadType
|
||||
}
|
||||
if len(payload) != 36 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("credential payload: got %d bytes, want 36", len(payload))
|
||||
}
|
||||
return l0, payload[0:16], payload[16:32], payload[32:36], nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CVT envelope: type(1) + L0(4) + AES-GCM(derived(L0), payload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -59,3 +59,65 @@ func TestCVT_unique(t *testing.T) {
|
|||
t.Fatal("two tokens with same input should differ (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test client credential (type 0x01) - used for agent testing
|
||||
func TestMintCredential_roundtrip(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xAB}, 16) // 16 bytes test key
|
||||
agentID := make([]byte, 16)
|
||||
for i := range agentID {
|
||||
agentID[i] = byte(0x40 + i)
|
||||
}
|
||||
pop := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
token, err := MintCredential(l0, l2, agentID, pop)
|
||||
if err != nil {
|
||||
t.Fatalf("MintCredential: %v", err)
|
||||
}
|
||||
|
||||
gotL0, gotL2, gotAgentID, gotPOP, err := ParseCredential(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCredential: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotL0, l0) {
|
||||
t.Fatalf("L0 mismatch: got %x, want %x", gotL0, l0)
|
||||
}
|
||||
if !bytes.Equal(gotL2, l2) {
|
||||
t.Fatalf("L2 mismatch: got %x, want %x", gotL2, l2)
|
||||
}
|
||||
if !bytes.Equal(gotAgentID, agentID) {
|
||||
t.Fatalf("agent_id mismatch: got %x, want %x", gotAgentID, agentID)
|
||||
}
|
||||
if !bytes.Equal(gotPOP, pop) {
|
||||
t.Fatalf("POP mismatch: got %x, want %x", gotPOP, pop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCredential_unique(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xCD}, 16)
|
||||
agentID := make([]byte, 16)
|
||||
pop := []byte{0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
t1, _ := MintCredential(l0, l2, agentID, pop)
|
||||
t2, _ := MintCredential(l0, l2, agentID, pop)
|
||||
if t1 == t2 {
|
||||
t.Fatal("two credential tokens with same input should differ (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCredential_tamper_detection(t *testing.T) {
|
||||
l0 := []byte{0x11, 0x22, 0x33, 0x44}
|
||||
l2 := bytes.Repeat([]byte{0xEF}, 16)
|
||||
agentID := make([]byte, 16)
|
||||
pop := []byte{0xFF, 0xFF, 0xFF, 0xFF}
|
||||
|
||||
token, _ := MintCredential(l0, l2, agentID, pop)
|
||||
|
||||
// Flip a character in the middle
|
||||
tampered := token[:10] + "X" + token[11:]
|
||||
_, _, _, _, err := ParseCredential(tampered)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on tampered credential token")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,35 @@ code { font-size: 0.875em; }
|
|||
.nav-dropdown-item { display: block; padding: 6px 16px; font-size: 0.825rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; }
|
||||
.nav-dropdown-item:hover { color: var(--text); background: var(--surface); }
|
||||
|
||||
/* === SPLIT LANGUAGE/CURRENCY SELECTORS === */
|
||||
.nav-dropdown--language,
|
||||
.nav-dropdown--currency { display: inline-block; }
|
||||
.nav-dropdown--language .nav-dropdown-trigger,
|
||||
.nav-dropdown--currency .nav-dropdown-trigger { min-width: 60px; justify-content: center; }
|
||||
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 8px; }
|
||||
@media (max-width: 768px) {
|
||||
.nav-dropdown--language,
|
||||
.nav-dropdown--currency { display: block; width: 100%; }
|
||||
.nav-dropdown--language + .nav-dropdown--currency { margin-left: 0; margin-top: 0.5rem; }
|
||||
.nav-dropdown--language .nav-dropdown-menu,
|
||||
.nav-dropdown--currency .nav-dropdown-menu { position: static; right: auto; left: auto; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; }
|
||||
}
|
||||
|
||||
/* === CURRENCY DROPDOWN SECTIONS === */
|
||||
.dropdown-section {
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dropdown-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* === BUTTONS === */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--font-sans); font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; transition: all 100ms ease; text-align: center; text-decoration: none; }
|
||||
.btn-primary { background: var(--brand-black); color: #ffffff; border-color: var(--brand-black); }
|
||||
|
|
|
|||
|
|
@ -104,19 +104,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link{{if eq .ActiveNav "pricing"}} active{{end}}">Pricing</a>
|
||||
<div class="nav-dropdown nav-dropdown--locale">
|
||||
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
|
||||
<div class="nav-dropdown nav-dropdown--language">
|
||||
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right">
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
|
||||
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
|
||||
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
|
||||
<a href="/fr" class="nav-dropdown-item" data-lang="fr">🇫🇷 Français</a>
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="CHF">CHF</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="GBP">GBP £</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown nav-dropdown--currency">
|
||||
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
|
||||
<!-- Currency options loaded dynamically from /api/currencies -->
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
|
||||
|
|
@ -173,61 +174,140 @@
|
|||
<script>
|
||||
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
|
||||
|
||||
// Locale selector
|
||||
// Language selector state management
|
||||
(function() {
|
||||
const localeTrigger = document.getElementById('localeTrigger');
|
||||
if (!localeTrigger) return;
|
||||
|
||||
const dropdown = localeTrigger.parentElement;
|
||||
const langTrigger = document.getElementById('languageTrigger');
|
||||
if (!langTrigger) return;
|
||||
|
||||
const dropdown = langTrigger.closest('.nav-dropdown--language');
|
||||
const langItems = dropdown.querySelectorAll('[data-lang]');
|
||||
const currencyItems = dropdown.querySelectorAll('[data-currency]');
|
||||
|
||||
// Load saved preferences
|
||||
const saved = JSON.parse(localStorage.getItem('clavitor-locale') || '{}');
|
||||
const currentLang = saved.lang || 'en';
|
||||
const currentCurrency = saved.currency || 'USD';
|
||||
|
||||
function updateDisplay() {
|
||||
const lang = dropdown.querySelector('[data-lang].active')?.dataset.lang || currentLang;
|
||||
const currency = dropdown.querySelector('[data-currency].active')?.dataset.currency || currentCurrency;
|
||||
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
|
||||
localeTrigger.textContent = `${langFlags[lang] || '🌐'} ${lang.toUpperCase()} / ${currency}`;
|
||||
}
|
||||
|
||||
// Set initial active states
|
||||
|
||||
// Load saved preference
|
||||
const savedLang = localStorage.getItem('preferredLanguage') || 'en';
|
||||
const langFlags = { en: '🇺🇸', de: '🇩🇪', fr: '🇫🇷' };
|
||||
|
||||
// Set initial active state
|
||||
langItems.forEach(el => {
|
||||
if (el.dataset.lang === currentLang) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
if (el.dataset.lang === savedLang) {
|
||||
el.classList.add('active');
|
||||
langTrigger.textContent = langFlags[savedLang] + ' ' + savedLang.toUpperCase();
|
||||
} else {
|
||||
el.classList.remove('active');
|
||||
}
|
||||
});
|
||||
currencyItems.forEach(el => {
|
||||
if (el.dataset.currency === currentCurrency) el.classList.add('active');
|
||||
else el.classList.remove('active');
|
||||
});
|
||||
updateDisplay();
|
||||
|
||||
|
||||
// Handle language selection
|
||||
langItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const lang = el.dataset.lang;
|
||||
const flag = el.textContent.trim().split(' ')[0];
|
||||
|
||||
langTrigger.textContent = flag + ' ' + lang.toUpperCase();
|
||||
langItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: el.dataset.lang, currency: currentCurrency }));
|
||||
updateDisplay();
|
||||
localStorage.setItem('preferredLanguage', lang);
|
||||
|
||||
// Navigate to language path
|
||||
if (el.dataset.lang === 'en') window.location.href = '/';
|
||||
else window.location.href = '/' + el.dataset.lang;
|
||||
}));
|
||||
|
||||
// Handle currency selection
|
||||
currencyItems.forEach(el => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
currencyItems.forEach(i => i.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
localStorage.setItem('clavitor-locale', JSON.stringify({ lang: currentLang, currency: el.dataset.currency }));
|
||||
updateDisplay();
|
||||
// Refresh page to apply currency (or fetch rates via JS)
|
||||
window.location.reload();
|
||||
if (lang === 'en') window.location.href = '/';
|
||||
else window.location.href = '/' + lang;
|
||||
}));
|
||||
})();
|
||||
|
||||
// Currency selector - fetch from API and render with sections
|
||||
(function() {
|
||||
const currencyTrigger = document.getElementById('currencyTrigger');
|
||||
if (!currencyTrigger) return;
|
||||
|
||||
async function loadCurrencies() {
|
||||
const menu = document.getElementById('currencyMenu');
|
||||
if (!menu) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/currencies');
|
||||
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
|
||||
const data = await response.json();
|
||||
|
||||
// Clear existing content
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Render "Popular" section
|
||||
if (data.top && data.top.length > 0) {
|
||||
const popularHeader = document.createElement('div');
|
||||
popularHeader.className = 'dropdown-section';
|
||||
popularHeader.textContent = 'Popular';
|
||||
menu.appendChild(popularHeader);
|
||||
|
||||
data.top.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, currencyTrigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Divider between sections
|
||||
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
menu.appendChild(divider);
|
||||
}
|
||||
|
||||
// Render "All Currencies" section
|
||||
if (data.all && data.all.length > 0) {
|
||||
const allHeader = document.createElement('div');
|
||||
allHeader.className = 'dropdown-section';
|
||||
allHeader.textContent = 'All Currencies';
|
||||
menu.appendChild(allHeader);
|
||||
|
||||
data.all.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, currencyTrigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// ERR-CURRENCY-002: API unavailable - keep default fallback options
|
||||
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
|
||||
}
|
||||
}
|
||||
|
||||
function createCurrencyItem(currency, trigger) {
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'nav-dropdown-item';
|
||||
item.setAttribute('data-currency', currency.code);
|
||||
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
|
||||
// Set active state based on current selection or saved preference
|
||||
const savedCurrency = localStorage.getItem('preferredCurrency') || 'USD';
|
||||
const currentText = trigger.textContent.trim();
|
||||
if (currentText.includes(currency.code) || savedCurrency === currency.code) {
|
||||
item.classList.add('active');
|
||||
if (savedCurrency === currency.code) {
|
||||
trigger.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
}
|
||||
}
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const code = item.getAttribute('data-currency');
|
||||
const symbol = currency.symbol || '$';
|
||||
trigger.textContent = symbol + ' ' + code;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('preferredCurrency', code);
|
||||
|
||||
// Refresh page to apply currency (or fetch rates via JS)
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Load currencies on page load
|
||||
loadCurrencies();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -46,16 +46,19 @@
|
|||
</div>
|
||||
</div>
|
||||
<a href="/pricing" class="nav-link active">Pricing</a>
|
||||
<div class="nav-dropdown nav-dropdown--locale">
|
||||
<span class="nav-link nav-dropdown-trigger" id="localeTrigger">🌐 EN / $</span>
|
||||
<div class="nav-dropdown nav-dropdown--language">
|
||||
<span class="nav-link nav-dropdown-trigger" id="languageTrigger">🇺🇸 EN</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right">
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Language</div>
|
||||
<a href="/" class="nav-dropdown-item active" data-lang="en">🇺🇸 English</a>
|
||||
<a href="/de" class="nav-dropdown-item" data-lang="de">🇩🇪 Deutsch</a>
|
||||
<div style="border-top:1px solid var(--border);margin:8px 0;"></div>
|
||||
<div style="padding:8px 16px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;">Currency</div>
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">USD $</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">EUR €</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown nav-dropdown--currency">
|
||||
<span class="nav-link nav-dropdown-trigger" id="currencyTrigger">$ USD</span>
|
||||
<div class="nav-dropdown-menu nav-dropdown-menu--right" id="currencyMenu">
|
||||
<!-- Currency options loaded dynamically from /api/currencies -->
|
||||
<a href="#" class="nav-dropdown-item active" data-currency="USD">$ USD</a>
|
||||
<a href="#" class="nav-dropdown-item" data-currency="EUR">€ EUR</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="nav-link btn btn-ghost">Sign in</a>
|
||||
|
|
@ -128,7 +131,104 @@
|
|||
</footer>
|
||||
|
||||
<script>
|
||||
// Toggle dropdown menus
|
||||
document.querySelectorAll('.nav-dropdown-trigger').forEach(t=>t.addEventListener('click',()=>t.parentElement.classList.toggle('open')));
|
||||
|
||||
// Language selector state management
|
||||
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const lang = item.getAttribute('data-lang');
|
||||
const flag = item.textContent.trim().split(' ')[0];
|
||||
document.getElementById('languageTrigger').textContent = flag + ' ' + lang.toUpperCase();
|
||||
document.querySelectorAll('.nav-dropdown--language .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Currency selector - fetch from API and render with sections
|
||||
async function loadCurrencies() {
|
||||
const menu = document.getElementById('currencyMenu');
|
||||
const trigger = document.getElementById('currencyTrigger');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/currencies');
|
||||
if (!response.ok) throw new Error('ERR-CURRENCY-001: Failed to load currencies');
|
||||
const data = await response.json();
|
||||
|
||||
// Clear existing content
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Render "Popular" section
|
||||
if (data.top && data.top.length > 0) {
|
||||
const popularHeader = document.createElement('div');
|
||||
popularHeader.className = 'dropdown-section';
|
||||
popularHeader.textContent = 'Popular';
|
||||
menu.appendChild(popularHeader);
|
||||
|
||||
data.top.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, trigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Divider between sections
|
||||
if (data.all && data.all.length > 0 && data.top && data.top.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
menu.appendChild(divider);
|
||||
}
|
||||
|
||||
// Render "All Currencies" section
|
||||
if (data.all && data.all.length > 0) {
|
||||
const allHeader = document.createElement('div');
|
||||
allHeader.className = 'dropdown-section';
|
||||
allHeader.textContent = 'All Currencies';
|
||||
menu.appendChild(allHeader);
|
||||
|
||||
data.all.forEach(currency => {
|
||||
const item = createCurrencyItem(currency, trigger);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// ERR-CURRENCY-002: API unavailable - keep default fallback options
|
||||
console.error('ERR-CURRENCY-002: Currency API unavailable, using defaults');
|
||||
}
|
||||
}
|
||||
|
||||
function createCurrencyItem(currency, trigger) {
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'nav-dropdown-item';
|
||||
item.setAttribute('data-currency', currency.code);
|
||||
item.textContent = (currency.symbol || '$') + ' ' + currency.code;
|
||||
|
||||
// Set active state based on current selection
|
||||
const currentText = trigger.textContent.trim();
|
||||
if (currentText.includes(currency.code)) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const code = item.getAttribute('data-currency');
|
||||
const symbol = currency.symbol || '$';
|
||||
trigger.textContent = symbol + ' ' + code;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.nav-dropdown--currency .nav-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('preferredCurrency', code);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Load currencies on page load
|
||||
loadCurrencies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue