Files
nebula/views/settings.templ

1143 lines
40 KiB
Plaintext
Raw Normal View History

package views
import (
"nebula/layouts"
"nebula/models"
"strconv"
"strings"
"time"
)
templ SettingsPage(data models.SettingsData, activeTab string) {
@layouts.DashboardLayout("Settings - Sonr", layouts.WalletUser{Name: "Sonr Wallet", Address: "sonr1x9f...7k2m"}, "settings") {
@settingsStyles()
<header class="page-header">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-xl">Settings</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Manage your account preferences and security</span>
</div>
</header>
<div class="settings-container">
<wa-tab-group class="settings-tabs">
<wa-tab panel="profile" active?={ activeTab == "" || activeTab == "profile" }>
<wa-icon name="user"></wa-icon>
Profile
</wa-tab>
<wa-tab panel="devices" active?={ activeTab == "devices" }>
<wa-icon name="laptop"></wa-icon>
Devices
</wa-tab>
<wa-tab panel="oauth" active?={ activeTab == "oauth" }>
<wa-icon name="key"></wa-icon>
OAuth
</wa-tab>
<wa-tab panel="notifications" active?={ activeTab == "notifications" }>
<wa-icon name="bell"></wa-icon>
Notifications
</wa-tab>
<wa-tab panel="emails" active?={ activeTab == "emails" }>
<wa-icon name="envelope"></wa-icon>
Emails
</wa-tab>
<wa-tab panel="phones" active?={ activeTab == "phones" }>
<wa-icon name="phone"></wa-icon>
Phone Numbers
</wa-tab>
<wa-tab panel="developer" active?={ activeTab == "developer" }>
<wa-icon name="code"></wa-icon>
Developer
</wa-tab>
<wa-tab-panel name="profile">
@ProfileTab(data.Profile)
</wa-tab-panel>
<wa-tab-panel name="devices">
@DevicesTab(data.Devices)
</wa-tab-panel>
<wa-tab-panel name="oauth">
@OAuthTab(data.OAuth)
</wa-tab-panel>
<wa-tab-panel name="notifications">
@NotificationsTab(data.Notifications)
</wa-tab-panel>
<wa-tab-panel name="emails">
@EmailsTab(data.Emails)
</wa-tab-panel>
<wa-tab-panel name="phones">
@PhonesTab(data.Phones, data.SMSSettings)
</wa-tab-panel>
<wa-tab-panel name="developer">
@DeveloperTab(data.Developer)
</wa-tab-panel>
</wa-tab-group>
</div>
@AddEmailDialog()
@AddPhoneDialog()
@AddDeviceDialog()
@GenerateKeyDialog()
@AddWebhookDialog()
@settingsScripts()
}
}
templ ProfileTab(profile models.ProfileSettings) {
<div class="wa-stack wa-gap-xl">
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Avatar</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Your public profile image</p>
</div>
<div class="avatar-upload">
<wa-avatar initials={ profile.Initials } style="background: linear-gradient(135deg, #17c2ff, #0090ff);"></wa-avatar>
<div class="wa-stack wa-gap-s">
<div class="wa-cluster wa-gap-s">
<wa-button size="small" variant="neutral" appearance="outlined">
<wa-icon slot="start" name="upload"></wa-icon>
Upload
</wa-button>
<wa-button size="small" variant="neutral" appearance="plain">Remove</wa-button>
</div>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">JPG, PNG or GIF. Max 2MB.</span>
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Display Name</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Shown on your public profile</p>
</div>
<wa-input value={ profile.DisplayName } placeholder="Enter display name" name="profile-display-name"></wa-input>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Username</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Your unique identifier</p>
</div>
<wa-input value={ profile.Username } placeholder="Enter username" name="profile-username">
<span slot="prefix">{ "@" }</span>
</wa-input>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Bio</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Brief description for your profile</p>
</div>
<wa-textarea rows="3" placeholder="Tell us about yourself..." value={ profile.Bio } name="profile-bio"></wa-textarea>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Website</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Your personal or project website</p>
</div>
<wa-input type="url" value={ profile.Website } placeholder="https://example.com" name="profile-website">
<wa-icon slot="prefix" name="globe"></wa-icon>
</wa-input>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Social Links</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Connect your social accounts</p>
</div>
<div class="wa-stack wa-gap-m">
<wa-input placeholder="twitter.com/username" value={ profile.SocialLinks.Twitter } name="profile-twitter">
<wa-icon slot="prefix" name="x-twitter" variant="brand"></wa-icon>
</wa-input>
<wa-input placeholder="github.com/username" value={ profile.SocialLinks.GitHub } name="profile-github">
<wa-icon slot="prefix" name="github" variant="brand"></wa-icon>
</wa-input>
<wa-input placeholder="discord.gg/invite" value={ profile.SocialLinks.Discord } name="profile-discord">
<wa-icon slot="prefix" name="discord" variant="brand"></wa-icon>
</wa-input>
</div>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Profile Visibility</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Control who can see your profile</p>
</div>
<wa-radio-group value={ profile.Visibility } name="profile-visibility">
<wa-radio value="public">
<div class="wa-stack wa-gap-0">
<span>Public</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Anyone can view your profile</span>
</div>
</wa-radio>
<wa-radio value="connections">
<div class="wa-stack wa-gap-0">
<span>Connections Only</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Only connected apps and users</span>
</div>
</wa-radio>
<wa-radio value="private">
<div class="wa-stack wa-gap-0">
<span>Private</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Only you can see your profile</span>
</div>
</wa-radio>
</wa-radio-group>
</div>
</div>
<div class="wa-cluster wa-gap-s" style="justify-content: flex-end;">
<wa-button variant="neutral" appearance="outlined">Cancel</wa-button>
<wa-button variant="brand" hx-post="/api/settings/profile" hx-include="[name^='profile-']">Save Changes</wa-button>
</div>
</div>
}
templ DevicesTab(devices []models.Device) {
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">Linked Devices</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Passkeys and security keys registered with your account</span>
</div>
<wa-button size="small" variant="brand" id="add-device-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Add Device
</wa-button>
</div>
<div class="wa-stack wa-gap-m" id="devices-list">
for _, device := range devices {
@DeviceItem(device)
}
</div>
<wa-callout variant="neutral">
<wa-icon slot="icon" name="shield-check"></wa-icon>
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-xs">Security Recommendation</span>
<span class="wa-caption-s">Register at least 2 devices to ensure account recovery if one is lost.</span>
</div>
</wa-callout>
</div>
}
templ DeviceItem(device models.Device) {
<div class="device-item" id={ "device-" + device.ID }>
<div class="device-icon">
<wa-icon name={ deviceIcon(device.Type) } style="font-size: 24px;"></wa-icon>
</div>
<div class="device-info">
<div class="wa-stack wa-gap-0">
<div class="wa-cluster wa-gap-xs">
<span class="wa-heading-s">{ device.Name }</span>
if device.IsCurrent {
<wa-badge variant="success" pill>Current</wa-badge>
}
</div>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
if device.Browser != "" {
{ device.Browser } on { device.OS } -
}
{ device.AuthType }
</span>
</div>
</div>
<div class="wa-stack wa-gap-0 wa-align-items-end">
<span class="wa-caption-s">Added { formatDate(device.AddedAt) }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Last used: { formatRelativeTime(device.LastUsed) }</span>
</div>
<wa-dropdown>
<wa-icon-button slot="trigger" name="ellipsis-vertical" label="Options"></wa-icon-button>
<wa-dropdown-item>
<wa-icon slot="icon" name="pen"></wa-icon>
Rename
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item
style="color: var(--wa-color-danger);"
hx-delete={ "/api/devices/" + device.ID }
hx-target={ "#device-" + device.ID }
hx-swap="delete"
hx-confirm="Remove this device?"
>
<wa-icon slot="icon" name="trash"></wa-icon>
Remove
</wa-dropdown-item>
</wa-dropdown>
</div>
}
func deviceIcon(deviceType string) string {
switch deviceType {
case "laptop":
return "laptop"
case "mobile":
return "mobile"
case "key":
return "key"
case "desktop":
return "desktop"
default:
return "laptop"
}
}
templ OAuthTab(oauth models.OAuthSettings) {
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">OAuth Settings</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Configure your identity provider settings</span>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Default Scopes</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">Permissions granted by default</p>
</div>
<div class="wa-stack wa-gap-s">
for _, scope := range oauth.DefaultScopes {
<wa-checkbox checked?={ scope.Enabled } disabled?={ scope.Required }>
<div class="wa-stack wa-gap-0">
<span>{ scope.Name }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ scope.Description }</span>
</div>
</wa-checkbox>
}
</div>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Session Duration</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">How long OAuth sessions remain valid</p>
</div>
<wa-select value={ oauth.SessionDuration }>
<wa-option value="1h">1 hour</wa-option>
<wa-option value="24h">24 hours</wa-option>
<wa-option value="7d">7 days</wa-option>
<wa-option value="30d">30 days</wa-option>
<wa-option value="never">Never expire</wa-option>
</wa-select>
</div>
</div>
<div class="form-section">
<div class="form-row">
<div class="form-label">
<span class="wa-heading-s">Consent Prompt</span>
<p class="wa-caption-s" style="color: var(--wa-color-neutral-500); margin: var(--wa-space-2xs) 0 0;">When to show permission dialogs</p>
</div>
<wa-radio-group value={ oauth.ConsentPrompt }>
<wa-radio value="always">Always prompt for consent</wa-radio>
<wa-radio value="first">Only on first connection</wa-radio>
<wa-radio value="scope">When new scopes requested</wa-radio>
</wa-radio-group>
</div>
</div>
<wa-divider></wa-divider>
<div class="wa-stack wa-gap-m">
<div class="wa-flank">
<span class="wa-heading-m">Authorized Clients</span>
<wa-button size="small" variant="neutral" appearance="outlined">View All</wa-button>
</div>
<div class="wa-stack wa-gap-s">
for _, client := range oauth.Clients {
@AuthorizedClientCard(client)
}
</div>
</div>
<div class="wa-cluster wa-gap-s" style="justify-content: flex-end;">
<wa-button variant="neutral" appearance="outlined">Cancel</wa-button>
<wa-button variant="brand">Save Changes</wa-button>
</div>
</div>
}
templ AuthorizedClientCard(client models.AuthorizedClient) {
<div class="oauth-client-card">
<div class="wa-flank">
<div class="wa-cluster wa-gap-m">
<wa-avatar initials={ client.Initials } style={ "--size: 40px; background: " + client.Color + ";" }></wa-avatar>
<div class="wa-stack wa-gap-0">
<span class="wa-heading-s">{ client.Name }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ client.Domain }</span>
</div>
</div>
<div class="wa-cluster wa-gap-s">
if client.Status == "active" {
<wa-badge variant="success" pill>Active</wa-badge>
} else {
<wa-badge variant="neutral" pill>Idle</wa-badge>
}
<wa-button size="small" variant="danger" appearance="plain" hx-post={ "/api/oauth/clients/" + client.ID + "/revoke" } hx-swap="none">Revoke</wa-button>
</div>
</div>
</div>
}
templ NotificationsTab(prefs models.NotificationPrefs) {
<div class="wa-stack wa-gap-xl">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">Notification Preferences</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Choose how and when you receive notifications</span>
</div>
<wa-card>
<span slot="header" class="wa-heading-s">Security Alerts</span>
<div class="wa-stack">
for _, item := range prefs.SecurityAlerts {
@NotificationRow(item)
}
</div>
</wa-card>
<wa-card>
<span slot="header" class="wa-heading-s">Transaction Notifications</span>
<div class="wa-stack">
for _, item := range prefs.Transactions {
@NotificationRow(item)
}
</div>
</wa-card>
<wa-card>
<span slot="header" class="wa-heading-s">App Notifications</span>
<div class="wa-stack">
for _, item := range prefs.Apps {
@NotificationRow(item)
}
</div>
</wa-card>
<wa-card>
<span slot="header" class="wa-heading-s">Marketing & Updates</span>
<div class="wa-stack">
for _, item := range prefs.Marketing {
@NotificationRow(item)
}
</div>
</wa-card>
<div class="wa-cluster wa-gap-s" style="justify-content: flex-end;">
<wa-button variant="brand" hx-post="/api/settings/notifications" hx-swap="none">Save Preferences</wa-button>
</div>
</div>
}
templ NotificationRow(item models.NotificationItem) {
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">{ item.Title }</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">{ item.Description }</span>
</div>
if item.Threshold > 0 {
<div class="wa-cluster wa-gap-s">
<wa-input type="number" value={ itoa(item.Threshold) } size="small" style="width: 100px;">
<span slot="suffix">USD</span>
</wa-input>
<wa-switch checked?={ item.Enabled } hx-post="/api/settings/notifications" hx-vals={ `{"key":"` + item.Key + `"}` } hx-swap="none"></wa-switch>
</div>
} else {
<wa-switch checked?={ item.Enabled } hx-post="/api/settings/notifications" hx-vals={ `{"key":"` + item.Key + `"}` } hx-swap="none"></wa-switch>
}
</div>
}
templ EmailsTab(emails []models.Email) {
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">Email Addresses</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Manage email addresses linked to your account</span>
</div>
<wa-button size="small" variant="brand" id="add-email-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Add Email
</wa-button>
</div>
<div class="wa-stack wa-gap-m" id="emails-list">
for _, email := range emails {
@EmailItem(email)
}
</div>
<wa-callout variant="neutral">
<wa-icon slot="icon" name="circle-info"></wa-icon>
<span class="wa-caption-s">Your primary email is used for account recovery and important notifications. You must have at least one verified email.</span>
</wa-callout>
</div>
}
templ EmailItem(email models.Email) {
<div class="contact-item" id={ "email-" + email.ID }>
if email.IsVerified {
<wa-icon name="envelope" style="font-size: 20px; color: var(--wa-color-neutral-500);"></wa-icon>
} else {
<wa-icon name="envelope" style="font-size: 20px; color: var(--wa-color-warning);"></wa-icon>
}
<div class="wa-stack wa-gap-0" style="flex: 1;">
<div class="wa-cluster wa-gap-xs">
<span class="wa-heading-s">{ email.Address }</span>
if email.IsPrimary {
<wa-badge variant="success" pill>Primary</wa-badge>
}
if email.IsVerified {
<wa-badge variant="brand" pill>Verified</wa-badge>
} else {
<wa-badge variant="warning" pill>Pending</wa-badge>
}
</div>
if email.IsVerified {
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Added { formatDate(email.AddedAt) }</span>
} else {
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Verification sent - check your inbox</span>
}
</div>
<wa-dropdown>
<wa-icon-button slot="trigger" name="ellipsis-vertical" label="Options"></wa-icon-button>
<wa-dropdown-item disabled?={ email.IsPrimary }>
<wa-icon slot="icon" name="star"></wa-icon>
Set as Primary
</wa-dropdown-item>
<wa-dropdown-item hx-post={ "/api/settings/emails/" + email.ID + "/resend" } hx-swap="none">
<wa-icon slot="icon" name="paper-plane"></wa-icon>
Resend Verification
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item
disabled?={ email.IsPrimary }
style="color: var(--wa-color-danger);"
hx-delete={ "/api/settings/emails/" + email.ID }
hx-target={ "#email-" + email.ID }
hx-swap="delete"
>
<wa-icon slot="icon" name="trash"></wa-icon>
Remove
</wa-dropdown-item>
</wa-dropdown>
</div>
}
templ PhonesTab(phones []models.Phone, smsSettings models.SMSSettings) {
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">Phone Numbers</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Manage phone numbers for 2FA and recovery</span>
</div>
<wa-button size="small" variant="brand" id="add-phone-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Add Phone
</wa-button>
</div>
<div class="wa-stack wa-gap-m" id="phones-list">
for _, phone := range phones {
@PhoneItem(phone)
}
</div>
<wa-card>
<span slot="header" class="wa-heading-s">SMS Authentication</span>
<div class="wa-stack">
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">Enable SMS 2FA</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Receive codes via SMS for high-risk actions</span>
</div>
<wa-switch checked?={ smsSettings.Enabled }></wa-switch>
</div>
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">SMS recovery codes</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Allow account recovery via SMS</span>
</div>
<wa-switch checked?={ smsSettings.RecoveryCodes }></wa-switch>
</div>
</div>
</wa-card>
<wa-callout variant="warning">
<wa-icon slot="icon" name="triangle-alert"></wa-icon>
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-xs">SMS Security Notice</span>
<span class="wa-caption-s">SMS-based authentication is less secure than passkeys. We recommend using passkeys as your primary authentication method.</span>
</div>
</wa-callout>
</div>
}
templ PhoneItem(phone models.Phone) {
<div class="contact-item" id={ "phone-" + phone.ID }>
<wa-icon name="phone" style="font-size: 20px; color: var(--wa-color-neutral-500);"></wa-icon>
<div class="wa-stack wa-gap-0" style="flex: 1;">
<div class="wa-cluster wa-gap-xs">
<span class="wa-heading-s">{ phone.Number }</span>
if phone.IsPrimary {
<wa-badge variant="success" pill>Primary</wa-badge>
}
if phone.IsVerified {
<wa-badge variant="brand" pill>Verified</wa-badge>
}
</div>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">
Added { formatDate(phone.AddedAt) }
if phone.Use2FA {
- Used for 2FA
}
</span>
</div>
<wa-dropdown>
<wa-icon-button slot="trigger" name="ellipsis-vertical" label="Options"></wa-icon-button>
<wa-dropdown-item disabled?={ phone.IsPrimary }>
<wa-icon slot="icon" name="star"></wa-icon>
Set as Primary
</wa-dropdown-item>
<wa-dropdown-item hx-post={ "/api/settings/phones/" + phone.ID + "/test" } hx-swap="none">
<wa-icon slot="icon" name="message"></wa-icon>
Send Test SMS
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item
disabled?={ phone.IsPrimary }
style="color: var(--wa-color-danger);"
hx-delete={ "/api/settings/phones/" + phone.ID }
hx-target={ "#phone-" + phone.ID }
hx-swap="delete"
>
<wa-icon slot="icon" name="trash"></wa-icon>
Remove
</wa-dropdown-item>
</wa-dropdown>
</div>
}
templ DeveloperTab(dev models.DeveloperSettings) {
<div class="wa-stack wa-gap-xl">
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-m">Developer Settings</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">API access and integration options</span>
</div>
<wa-card>
<div slot="header" class="wa-flank">
<span class="wa-heading-s">API Keys</span>
<wa-button size="small" variant="neutral" appearance="outlined" id="generate-key-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Generate Key
</wa-button>
</div>
<div class="wa-stack wa-gap-m" id="api-keys-list">
for _, key := range dev.APIKeys {
@APIKeyCard(key)
}
</div>
</wa-card>
<wa-card>
<div slot="header" class="wa-flank">
<span class="wa-heading-s">Webhooks</span>
<wa-button size="small" variant="neutral" appearance="outlined" id="add-webhook-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Add Endpoint
</wa-button>
</div>
<div class="wa-stack wa-gap-m" id="webhooks-list">
for _, wh := range dev.Webhooks {
@WebhookCard(wh)
}
</div>
</wa-card>
@OAuthAppConfigCard(dev.OAuthApp)
<wa-card>
<span slot="header" class="wa-heading-s">Developer Mode</span>
<div class="wa-stack">
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">Enable debug logging</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Log detailed API requests and responses</span>
</div>
<wa-switch checked?={ dev.DebugMode }></wa-switch>
</div>
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">Test mode</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Use testnet for all transactions</span>
</div>
<wa-switch checked?={ dev.TestMode }></wa-switch>
</div>
<div class="notification-row">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">Show raw responses</span>
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Display JSON in UI for debugging</span>
</div>
<wa-switch checked?={ dev.ShowRaw }></wa-switch>
</div>
</div>
</wa-card>
<wa-callout variant="neutral">
<wa-icon slot="icon" name="book"></wa-icon>
<div class="wa-stack wa-gap-2xs">
<span class="wa-heading-xs">API Documentation</span>
<span class="wa-caption-s">
View our <a href="#" style="color: var(--wa-color-primary);">API reference</a> and
<a href="#" style="color: var(--wa-color-primary);">integration guides</a> to get started.
</span>
</div>
</wa-callout>
<div class="wa-cluster wa-gap-s" style="justify-content: flex-end;">
<wa-button variant="neutral" appearance="outlined">Cancel</wa-button>
<wa-button variant="brand">Save Changes</wa-button>
</div>
</div>
}
templ APIKeyCard(key models.APIKey) {
<div class="wa-stack wa-gap-s">
<div class="wa-flank">
<div class="wa-stack wa-gap-0">
<span class="wa-heading-xs">{ key.Name }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Created { formatDate(key.CreatedAt) }</span>
</div>
if key.Environment == "live" {
<wa-badge variant="success" pill>Active</wa-badge>
} else {
<wa-badge variant="neutral" pill>Test Mode</wa-badge>
}
</div>
<div class="api-key-display">
<span>{ key.KeyPreview }</span>
<wa-copy-button value={ key.KeyFull }></wa-copy-button>
<wa-tooltip content="Reveal key">
<wa-icon-button name="eye" label="Reveal"></wa-icon-button>
</wa-tooltip>
</div>
</div>
}
templ WebhookCard(wh models.Webhook) {
<div class="webhook-item">
<div class="wa-flank">
<div class="wa-stack wa-gap-0">
<div class="wa-cluster wa-gap-xs">
<span class="wa-heading-xs">{ wh.URL }</span>
if wh.Status == "active" {
<wa-badge variant="success" pill>Active</wa-badge>
} else {
<wa-badge variant="warning" pill>Failing</wa-badge>
}
</div>
if wh.Status == "failing" {
<span class="wa-caption-xs" style="color: var(--wa-color-danger);">{ formatEvents(wh.Events) } events - { itoa(wh.FailureCount) } failures in last hour</span>
} else {
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ formatEvents(wh.Events) } events - Last triggered { formatRelativeTime(wh.LastTriggered) }</span>
}
</div>
<wa-dropdown>
<wa-icon-button slot="trigger" name="ellipsis-vertical" label="Options"></wa-icon-button>
<wa-dropdown-item>
<wa-icon slot="icon" name="pen"></wa-icon>
Edit
</wa-dropdown-item>
<wa-dropdown-item hx-post={ "/api/webhooks/" + wh.ID + "/test" } hx-swap="none">
<wa-icon slot="icon" name="paper-plane"></wa-icon>
Send Test
</wa-dropdown-item>
<wa-dropdown-item>
<wa-icon slot="icon" name="clock-rotate-left"></wa-icon>
View Logs
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item style="color: var(--wa-color-danger);" hx-delete={ "/api/webhooks/" + wh.ID } hx-target="closest .webhook-item" hx-swap="delete">
<wa-icon slot="icon" name="trash"></wa-icon>
Delete
</wa-dropdown-item>
</wa-dropdown>
</div>
</div>
}
templ OAuthAppConfigCard(config models.OAuthAppConfig) {
<wa-card>
<span slot="header" class="wa-heading-s">OAuth Application</span>
<div class="wa-stack wa-gap-l">
<div class="form-row" style="grid-template-columns: 140px 1fr;">
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Client ID</span>
<div class="wa-cluster wa-gap-s">
<code style="font-family: var(--wa-font-mono); font-size: var(--wa-font-size-s);">{ config.ClientID }</code>
<wa-copy-button value={ config.ClientID }></wa-copy-button>
</div>
</div>
<div class="form-row" style="grid-template-columns: 140px 1fr;">
<span class="wa-caption-s" style="color: var(--wa-color-neutral-500);">Client Secret</span>
<div class="wa-cluster wa-gap-s">
<code style="font-family: var(--wa-font-mono); font-size: var(--wa-font-size-s);">••••••••••••••••</code>
<wa-copy-button value={ config.ClientSecret }></wa-copy-button>
<wa-button size="small" appearance="plain">Regenerate</wa-button>
</div>
</div>
<wa-divider></wa-divider>
<div class="wa-stack wa-gap-s">
<span class="wa-heading-xs">Redirect URIs</span>
for _, uri := range config.RedirectURIs {
<wa-input value={ uri } placeholder="https://example.com/callback">
<wa-icon-button slot="suffix" name="trash" label="Remove"></wa-icon-button>
</wa-input>
}
<wa-button size="small" appearance="plain">
<wa-icon slot="start" name="plus"></wa-icon>
Add URI
</wa-button>
</div>
</div>
</wa-card>
}
templ AddEmailDialog() {
<wa-dialog label="Add Email Address" id="add-email-dialog" style="--width: 420px;">
<div class="wa-stack wa-gap-l">
<wa-input type="email" label="Email Address" placeholder="you@example.com" autofocus name="email">
<wa-icon slot="prefix" name="envelope"></wa-icon>
</wa-input>
<wa-callout variant="neutral">
<wa-icon slot="icon" name="circle-info"></wa-icon>
<span class="wa-caption-s">We'll send a verification link to this address.</span>
</wa-callout>
</div>
<div slot="footer" class="wa-cluster wa-gap-s" style="justify-content: flex-end; width: 100%;">
<wa-button variant="neutral" appearance="outlined" data-dialog="close">Cancel</wa-button>
<wa-button variant="brand" hx-post="/api/settings/emails" hx-target="#emails-list" hx-swap="beforeend">Send Verification</wa-button>
</div>
</wa-dialog>
}
templ AddPhoneDialog() {
<wa-dialog label="Add Phone Number" id="add-phone-dialog" style="--width: 420px;">
<div class="wa-stack wa-gap-l">
<div class="wa-cluster wa-gap-s">
<wa-select value="+1" style="width: 100px;" name="country-code">
<wa-option value="+1">+1</wa-option>
<wa-option value="+44">+44</wa-option>
<wa-option value="+49">+49</wa-option>
<wa-option value="+33">+33</wa-option>
<wa-option value="+81">+81</wa-option>
</wa-select>
<wa-input type="tel" label="Phone Number" placeholder="(555) 123-4567" style="flex: 1;" name="phone">
<wa-icon slot="prefix" name="phone"></wa-icon>
</wa-input>
</div>
<wa-callout variant="neutral">
<wa-icon slot="icon" name="circle-info"></wa-icon>
<span class="wa-caption-s">We'll send a verification code via SMS.</span>
</wa-callout>
</div>
<div slot="footer" class="wa-cluster wa-gap-s" style="justify-content: flex-end; width: 100%;">
<wa-button variant="neutral" appearance="outlined" data-dialog="close">Cancel</wa-button>
<wa-button variant="brand" hx-post="/api/settings/phones" hx-target="#phones-list" hx-swap="beforeend">Send Code</wa-button>
</div>
</wa-dialog>
}
templ AddDeviceDialog() {
<wa-dialog label="Add Device" id="add-device-dialog" style="--width: 480px;">
<div class="wa-stack wa-gap-l" style="text-align: center;">
<wa-icon name="fingerprint" style="font-size: 64px; color: var(--wa-color-primary);"></wa-icon>
<div class="wa-stack wa-gap-xs">
<span class="wa-heading-m">Register a Passkey</span>
<span class="wa-caption-m" style="color: var(--wa-color-neutral-500);">
Use your device's built-in authenticator (Face ID, Touch ID, Windows Hello) or a security key.
</span>
</div>
<wa-button variant="brand" size="large" style="width: 100%;" id="register-passkey-btn">
<wa-icon slot="start" name="plus"></wa-icon>
Register Device
</wa-button>
<wa-divider>or</wa-divider>
<wa-button variant="neutral" appearance="outlined" style="width: 100%;" id="register-security-key-btn">
<wa-icon slot="start" name="key"></wa-icon>
Use Security Key
</wa-button>
</div>
<div slot="footer" class="wa-cluster" style="justify-content: center; width: 100%;">
<wa-button variant="neutral" appearance="plain" data-dialog="close">Cancel</wa-button>
</div>
</wa-dialog>
}
templ GenerateKeyDialog() {
<wa-dialog label="Generate API Key" id="generate-key-dialog" style="--width: 420px;">
<div class="wa-stack wa-gap-l">
<wa-input label="Key Name" placeholder="e.g., Production Server" autofocus name="key-name">
<wa-icon slot="prefix" name="tag"></wa-icon>
</wa-input>
<wa-select label="Environment" value="test" name="key-env">
<wa-option value="test">Test Mode</wa-option>
<wa-option value="live">Production</wa-option>
</wa-select>
<wa-select label="Permissions" value="full" name="key-perms">
<wa-option value="full">Full Access</wa-option>
<wa-option value="read">Read Only</wa-option>
<wa-option value="write">Write Only</wa-option>
</wa-select>
<wa-callout variant="warning">
<wa-icon slot="icon" name="triangle-alert"></wa-icon>
<span class="wa-caption-s">Your secret key will only be shown once. Store it securely.</span>
</wa-callout>
</div>
<div slot="footer" class="wa-cluster wa-gap-s" style="justify-content: flex-end; width: 100%;">
<wa-button variant="neutral" appearance="outlined" data-dialog="close">Cancel</wa-button>
<wa-button variant="brand" hx-post="/api/settings/api-keys" hx-target="#api-keys-list" hx-swap="beforeend">Generate Key</wa-button>
</div>
</wa-dialog>
}
templ AddWebhookDialog() {
<wa-dialog label="Add Webhook Endpoint" id="add-webhook-dialog" style="--width: 480px;">
<div class="wa-stack wa-gap-l">
<wa-input label="Endpoint URL" placeholder="https://api.example.com/webhooks" type="url" name="webhook-url">
<wa-icon slot="prefix" name="globe"></wa-icon>
</wa-input>
<wa-input label="Description" placeholder="Optional description" name="webhook-desc">
<wa-icon slot="prefix" name="align-left"></wa-icon>
</wa-input>
<div class="wa-stack wa-gap-s">
<span class="wa-heading-xs">Events to Subscribe</span>
<wa-checkbox checked name="webhook-event-tx">
<div class="wa-stack wa-gap-0">
<span>transaction.*</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">All transaction events</span>
</div>
</wa-checkbox>
<wa-checkbox checked name="webhook-event-conn">
<div class="wa-stack wa-gap-0">
<span>connection.*</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">App connection events</span>
</div>
</wa-checkbox>
<wa-checkbox name="webhook-event-sig">
<div class="wa-stack wa-gap-0">
<span>signature.*</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Signature request events</span>
</div>
</wa-checkbox>
<wa-checkbox name="webhook-event-acct">
<div class="wa-stack wa-gap-0">
<span>account.*</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Account update events</span>
</div>
</wa-checkbox>
</div>
</div>
<div slot="footer" class="wa-cluster wa-gap-s" style="justify-content: flex-end; width: 100%;">
<wa-button variant="neutral" appearance="outlined" data-dialog="close">Cancel</wa-button>
<wa-button variant="brand" hx-post="/api/settings/webhooks" hx-target="#webhooks-list" hx-swap="beforeend">Create Webhook</wa-button>
</div>
</wa-dialog>
}
func itoa(i int) string {
return strconv.Itoa(i)
}
func formatDate(t time.Time) string {
return t.Format("Jan 2, 2006")
}
func formatRelativeTime(t time.Time) string {
diff := time.Since(t)
if diff < time.Minute {
return "Just now"
}
if diff < time.Hour {
mins := int(diff.Minutes())
if mins == 1 {
return "1 minute ago"
}
return strconv.Itoa(mins) + " minutes ago"
}
if diff < 24*time.Hour {
hours := int(diff.Hours())
if hours == 1 {
return "1 hour ago"
}
return strconv.Itoa(hours) + " hours ago"
}
days := int(diff.Hours() / 24)
if days == 1 {
return "1 day ago"
}
if days < 14 {
return strconv.Itoa(days) + " days ago"
}
weeks := days / 7
if weeks == 1 {
return "1 week ago"
}
return strconv.Itoa(weeks) + " weeks ago"
}
func formatEvents(events []string) string {
if len(events) == 1 && events[0] == "*" {
return "All"
}
return strings.Join(events, ", ")
}
templ settingsStyles() {
<style>
.page-header {
margin-bottom: var(--wa-space-xl);
}
.settings-container {
background: var(--wa-color-surface);
border-radius: var(--wa-radius-l);
overflow: hidden;
}
.settings-tabs {
--indicator-color: var(--wa-color-primary);
}
.settings-tabs wa-tab-panel {
padding: var(--wa-space-xl);
}
.settings-tabs wa-tab-group::part(base) {
display: flex;
flex-direction: column;
}
.settings-tabs wa-tab-group::part(nav) {
background: var(--wa-color-surface);
border-bottom: 1px solid var(--wa-color-neutral-200);
padding: 0 var(--wa-space-m);
}
.form-section {
padding: var(--wa-space-l) 0;
border-bottom: 1px solid var(--wa-color-neutral-100);
}
.form-section:first-child {
padding-top: 0;
}
.form-section:last-child {
border-bottom: none;
padding-bottom: 0;
}
.form-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--wa-space-xl);
align-items: start;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
gap: var(--wa-space-s);
}
}
.form-label {
padding-top: var(--wa-space-xs);
}
.avatar-upload {
display: flex;
align-items: center;
gap: var(--wa-space-l);
}
.avatar-upload wa-avatar {
--size: 80px;
}
.device-item {
display: flex;
align-items: center;
gap: var(--wa-space-m);
padding: var(--wa-space-m);
background: var(--wa-color-surface-alt);
border-radius: var(--wa-radius-m);
}
.device-icon {
width: 48px;
height: 48px;
border-radius: var(--wa-radius-m);
display: flex;
align-items: center;
justify-content: center;
background: var(--wa-color-surface);
border: 1px solid var(--wa-color-neutral-200);
}
.device-info {
flex: 1;
}
.contact-item {
display: flex;
align-items: center;
gap: var(--wa-space-m);
padding: var(--wa-space-m);
background: var(--wa-color-surface-alt);
border-radius: var(--wa-radius-m);
}
.api-key-display {
display: flex;
align-items: center;
gap: var(--wa-space-s);
padding: var(--wa-space-m);
background: var(--wa-color-neutral-900);
color: var(--wa-color-neutral-100);
border-radius: var(--wa-radius-m);
font-family: var(--wa-font-mono);
font-size: var(--wa-font-size-s);
}
.api-key-display span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.webhook-item {
padding: var(--wa-space-m);
background: var(--wa-color-surface-alt);
border-radius: var(--wa-radius-m);
}
.notification-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--wa-space-m) 0;
border-bottom: 1px solid var(--wa-color-neutral-100);
}
.notification-row:last-child {
border-bottom: none;
}
.oauth-client-card {
padding: var(--wa-space-m);
background: var(--wa-color-surface-alt);
border-radius: var(--wa-radius-m);
}
</style>
}
templ settingsScripts() {
<script>
(function() {
const addEmailDialog = document.getElementById('add-email-dialog');
const addPhoneDialog = document.getElementById('add-phone-dialog');
const addDeviceDialog = document.getElementById('add-device-dialog');
const generateKeyDialog = document.getElementById('generate-key-dialog');
const addWebhookDialog = document.getElementById('add-webhook-dialog');
document.getElementById('add-email-btn')?.addEventListener('click', () => {
addEmailDialog.open = true;
});
document.getElementById('add-phone-btn')?.addEventListener('click', () => {
addPhoneDialog.open = true;
});
document.getElementById('add-device-btn')?.addEventListener('click', () => {
addDeviceDialog.open = true;
});
document.getElementById('generate-key-btn')?.addEventListener('click', () => {
generateKeyDialog.open = true;
});
document.getElementById('add-webhook-btn')?.addEventListener('click', () => {
addWebhookDialog.open = true;
});
document.querySelectorAll('[data-dialog="close"]').forEach(btn => {
btn.addEventListener('click', () => {
btn.closest('wa-dialog').open = false;
});
});
})();
</script>
}