Settings blocks
Account and preference screens with grouped forms, toggles, and inline saving.
Billing settings
Plan, payment method and invoice history — a dense billing page that ships zero JavaScript, drawn from the same Arden invoices as the data table
-
components
-
billing_panel
-
-
templates
-
pages
-
<main class="container mx-auto max-w-5xl px-4 py-10 sm:py-14 flex flex-col gap-8">
<c-billing-panel.header :workspace=workspace :plan=plan :totals=totals />
<div class="grid lg:grid-cols-[1fr_18rem] gap-8">
<div class="flex flex-col gap-6">
<c-billing-panel.plan :workspace=workspace :plan=plan />
<c-billing-panel.invoices :workspace=workspace :invoices=invoices :totals=totals />
</div>
<aside class="flex flex-col gap-6">
<c-billing-panel.method :workspace=workspace :card=card />
<c-billing-panel.contacts :workspace=workspace :contacts=contacts />
</aside>
</div>
</main>
<c-vars :workspace="{}" :contacts="[]" />
<c-lb.card class="border border-base-300">
<c-lb.card.body class="gap-4 p-5">
<h2 class="text-sm font-semibold">Billing contacts</h2>
<div class="flex flex-col gap-3">
{% for contact in contacts %}
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ contact.initials }}" bgColor="accent" size="sm" />
<div class="text-xs min-w-0">
<div class="font-medium truncate">{{ contact.name }}</div>
<div class="text-base-content/50 truncate">{{ contact.email }}</div>
</div>
</div>
{% empty %}
<p class="text-xs text-base-content/50">
No one holds the billing role. Invoices go to {{ workspace.billing_email }} only.
</p>
{% endfor %}
</div>
<div class="border-t border-base-300 pt-3 flex flex-col gap-2">
<p class="text-xs text-base-content/60 leading-relaxed">
Need a purchase order on the invoice, or a different billing cycle?
Both can be set up without a call.
</p>
<a class="link link-hover text-xs text-base-content/50">Ask for a custom invoice</a>
</div>
</c-lb.card.body>
</c-lb.card>
<c-vars :workspace="{}" :plan="{}" :totals="{}" />
<div class="flex flex-col gap-6 border-b border-base-300 pb-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Billing</h1>
<p class="text-sm text-base-content/60 mt-1">
What {{ workspace.name }} pays Arden, and what Arden has billed on your behalf.
</p>
</div>
<c-lb.badge style="soft" variant="primary" icon="rmx.bank-card">{{ plan.name }}</c-lb.badge>
</div>
<div class="grid sm:grid-cols-3 gap-4">
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Next invoice</div>
<div class="text-xl font-semibold tabular-nums mt-1">
{{ workspace.currency }} {{ totals.next_amount }}
</div>
<div class="text-xs text-base-content/50 mt-0.5">{{ plan.renews_on }}</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Outstanding</div>
<div class="text-xl font-semibold tabular-nums mt-1">
{{ workspace.currency }} {{ totals.outstanding }}
</div>
<div class="text-xs text-base-content/50 mt-0.5">Across two open invoices</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Billed in 2026</div>
<div class="text-xl font-semibold tabular-nums mt-1">
{{ workspace.currency }} {{ totals.paid_this_year }}
</div>
<div class="text-xs text-base-content/50 mt-0.5">Paid, excluding voided runs</div>
</div>
</div>
</div>
<c-vars :workspace="{}" :invoices="[]" :totals="{}" />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-4 p-6 sm:p-8">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="text-lg font-semibold tracking-tight">Invoice history</h2>
<p class="text-sm text-base-content/60 mt-1">
Every billing run Arden has made for {{ workspace.name }}.
</p>
</div>
<c-lb.button btnStyle="outline" size="sm" icon="rmx.download">Download all</c-lb.button>
</div>
{% if invoices %}
<div class="overflow-x-auto border border-base-300 rounded-box">
<c-lb.table size="sm" class="w-full">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Period</th>
<th>Issued</th>
<th>Due</th>
<th class="text-right">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td class="font-medium tabular-nums whitespace-nowrap">{{ invoice.number }}</td>
<td class="whitespace-nowrap">{{ invoice.customer }}</td>
<td class="text-base-content/60 whitespace-nowrap">{{ invoice.period }}</td>
<td class="tabular-nums text-base-content/60 whitespace-nowrap">{{ invoice.issued }}</td>
<td class="tabular-nums text-base-content/60 whitespace-nowrap">{{ invoice.due }}</td>
<td class="text-right tabular-nums font-medium whitespace-nowrap">
{{ workspace.currency }} {{ invoice.amount }}
</td>
<td>
<c-lb.badge size="sm" style="soft" variant="{{ invoice.tone }}">
{{ invoice.status }}
</c-lb.badge>
</td>
<td class="text-right">
<c-lb.button
btnStyle="ghost"
size="xs"
modifier="square"
icon="rmx.download"
aria-label="Download {{ invoice.number }}" />
</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 text-xs">
<span class="text-base-content/50 tabular-nums">
{{ workspace.currency }} {{ totals.outstanding }} outstanding. Voided runs are kept
for the audit log and are never charged.
</span>
<a class="link link-hover text-base-content/50">See all 327 invoices</a>
</div>
{% else %}
<div class="border border-dashed border-base-300 rounded-box p-10 text-center">
<p class="font-medium">No invoices yet.</p>
<p class="text-sm text-base-content/60 mt-1">
The first one appears the day after your first customer renews.
</p>
</div>
{% endif %}
</c-lb.card.body>
</c-lb.card>
<c-vars :workspace="{}" :card="{}" />
<c-lb.card class="border border-base-300">
<c-lb.card.body class="gap-4 p-5">
<h2 class="text-sm font-semibold">Payment method</h2>
<div class="rounded-box bg-base-200
border border-base-300 p-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold tracking-wide">{{ card.brand }}</span>
<c-lbi n="rmx.bank-card" w="1em" h="1em" class="size-5 text-base-content/50" />
</div>
<div class="font-mono tabular-nums text-base tracking-widest">
•••• •••• •••• {{ card.last4 }}
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span class="truncate">{{ card.holder }}</span>
<span class="tabular-nums whitespace-nowrap">Expires {{ card.expires }}</span>
</div>
</div>
<c-lb.button btnStyle="outline" size="sm" class="w-full">Update card</c-lb.button>
<div class="flex flex-col gap-1 border-t border-base-300 pt-3 text-xs text-base-content/50">
<span class="font-medium text-base-content/70">Billed to</span>
<span>{{ workspace.address }}</span>
<span class="tabular-nums">VAT {{ workspace.tax_id }}</span>
</div>
<p class="text-xs text-base-content/45">
A failed charge is retried on days 3 and 7 before the workspace is limited.
</p>
</c-lb.card.body>
</c-lb.card>
<c-vars :workspace="{}" :plan="{}" />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-5 p-6 sm:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold tracking-tight">{{ plan.name }}</h2>
<p class="text-sm text-base-content/60 mt-1">{{ plan.tagline }}</p>
</div>
<div class="text-right">
<div class="text-2xl font-semibold tabular-nums">${{ plan.price_monthly }}</div>
<div class="text-xs text-base-content/50">per month</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-xs">
<span class="text-base-content/55">Seats in use</span>
<span class="tabular-nums font-medium">
{{ workspace.seats_used }} of {{ workspace.seats_total }}
</span>
</div>
<c-lb.progress variant="primary" value="{{ workspace.seats_pct }}" max="100" class="w-full h-2" />
<p class="text-xs text-base-content/45">
Suspended members do not hold a seat and are not billed.
</p>
</div>
<ul class="grid sm:grid-cols-2 gap-2 border-t border-base-300 pt-4">
{% for feature in plan.features %}
<li class="flex items-start gap-2 text-sm">
<c-lbi n="rmx.check" w="1em" h="1em" class="size-4 mt-0.5 text-success shrink-0" />
<span>{{ feature }}</span>
</li>
{% endfor %}
</ul>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-300 pt-4">
<p class="text-xs text-base-content/50 tabular-nums">
Pay yearly: ${{ plan.price_yearly }} — two months free.
</p>
<div class="flex items-center gap-2">
<c-lb.button btnStyle="ghost" size="sm">Switch to yearly</c-lb.button>
<c-lb.button btnStyle="outline" size="sm">Change plan</c-lb.button>
</div>
</div>
</c-lb.card.body>
</c-lb.card>
Learn from this block
-
There is no `c-lbr.*` here and no reactive prop, so labb loads no runtime at all. A billing page is read far more often than it is clicked, and this one is pure HTML: view the source and there is no script tag with a src.
-
ARD-2026-0026, Verity Labs, 468.00. These are the Arden invoices the data-table block sorts and filters — one fixture universe, so the gallery reads as one product rather than eight demos.
-
`tabular-nums` on every column of figures. It is the cheapest thing on this page and the most obvious difference between a template and a product.
-
The payment card is a `base-200` panel with a `base-300` hairline, not a fixed brand colour, so it recolours with the theme instead of fighting it. No hex ever appears in a block.
Profile settings
Personal details with an avatar upload that previews in the browser — the chosen file becomes an object URL held in a signal, so nothing is uploaded until the form is saved
-
components
-
profile_card
-
-
templates
-
pages
-
<script>
function ardenPickAvatar(evt) {
var file = evt.target.files && evt.target.files[0];
if (!file) {
return {url: '', name: ''};
}
return {url: URL.createObjectURL(file), name: file.name};
}
</script>
<c-lbr.signals id="profile-avatar" :$avatar=avatar />
<main class="container mx-auto max-w-5xl px-4 py-10 sm:py-14 flex flex-col gap-8">
<c-profile-card.header :member=member />
<div class="grid lg:grid-cols-[1fr_18rem] gap-8">
<div class="flex flex-col gap-6">
<c-profile-card.avatar :member=member />
<c-profile-card.details :member=member :timezones=timezones />
</div>
<aside class="flex flex-col gap-6">
<c-profile-card.security :member=member :sessions=sessions />
<c-profile-card.activity :activity=activity />
</aside>
</div>
</main>
<c-vars :activity="[]" />
<c-lb.card class="border border-base-300">
<c-lb.card.body class="gap-4 p-5">
<h2 class="text-sm font-semibold">Your recent activity</h2>
<div class="flex flex-col gap-3">
{% for item in activity %}
<div class="text-xs">
<div class="font-medium leading-snug">{{ item.label }}</div>
<div class="text-base-content/45 tabular-nums mt-0.5">{{ item.when }}</div>
</div>
{% endfor %}
</div>
<a class="link link-hover text-xs text-base-content/50">See the full audit log</a>
</c-lb.card.body>
</c-lb.card>
<c-vars :member="{}" />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-5 p-6 sm:p-8">
<div>
<h2 class="text-lg font-semibold tracking-tight">Photo</h2>
<p class="text-sm text-base-content/60 mt-1">
A face makes an approval easier to trust. PNG or JPEG, up to 2 MB.
</p>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="relative">
<div id="avatar-fallback" data-show="!$avatar.url">
<c-lb.avatar
placeholder
initials="{{ member.initials }}"
bgColor="primary"
size="xl"
ring
ringColor="primary" />
</div>
<img
id="avatar-preview"
alt="The photo you just chose"
class="size-20 rounded-full object-cover ring-2 ring-primary ring-offset-base-100 ring-offset-2"
data-show="$avatar.url"
data-attr:src="$avatar.url"
style="display:none"
/>
</div>
<div class="flex flex-col gap-2">
<c-lb.file-input
id="avatar-file"
size="sm"
accept="image/png,image/jpeg"
class="w-64"
data-on:change="$avatar = ardenPickAvatar(evt)" />
<div class="flex items-center gap-3 text-xs">
<span id="avatar-filename" class="text-base-content/50 truncate max-w-48"
data-text="$avatar.name || 'Nothing chosen yet'">Nothing chosen yet</span>
<button
id="avatar-clear"
type="button"
class="link link-hover text-base-content/50"
data-show="$avatar.url"
data-on:click="$avatar = {url: '', name: ''}"
style="display:none"
>Remove</button>
</div>
</div>
</div>
<p class="text-xs text-base-content/45 border-t border-base-300 pt-4">
The preview is local to this browser. Nothing is uploaded until you save.
</p>
</c-lb.card.body>
</c-lb.card>
<c-vars :member="{}" :timezones="[]" />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-6 p-6 sm:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold tracking-tight">Personal details</h2>
<p class="text-sm text-base-content/60 mt-1">
Your name and title appear on every approval and invoice you touch.
</p>
</div>
<c-lb.button variant="primary" size="sm">Save changes</c-lb.button>
</div>
<div class="grid sm:grid-cols-2 gap-5">
<div>
<label class="label label-text text-sm font-medium mb-1">Full name</label>
<c-lb.input id="pf-name" type="text" class="w-full" value="{{ member.name }}" data-lbb-start />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">Job title</label>
<c-lb.input id="pf-title" type="text" class="w-full" value="{{ member.title }}" />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">
Work email <span class="text-base-content/40 font-normal">(used to sign in)</span>
</label>
<c-lb.input id="pf-email" type="email" class="w-full" value="{{ member.email }}" />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">Phone</label>
<c-lb.input id="pf-phone" type="tel" class="w-full tabular-nums" value="{{ member.phone }}" />
</div>
<div class="sm:col-span-2">
<label class="label label-text text-sm font-medium mb-1">
Timezone <span class="text-base-content/40 font-normal">(when your digest arrives)</span>
</label>
<c-lb.select id="pf-timezone" class="w-full">
{% for value, label in timezones %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</div>
</div>
<div class="flex items-start gap-2 text-xs text-base-content/50 border-t border-base-300 pt-4">
<c-lbi n="rmx.information" w="1em" h="1em" class="size-4 mt-0.5 shrink-0" />
<span>
Changing your work email signs you out of every device and sends a
confirmation link to the new address.
</span>
</div>
</c-lb.card.body>
</c-lb.card>
<c-vars :member="{}" />
<div class="flex flex-wrap items-end justify-between gap-4 border-b border-base-300 pb-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Your profile</h1>
<p class="text-sm text-base-content/60 mt-1">
How you appear to the rest of {{ member.workspace }}, and how Arden reaches you.
</p>
</div>
<div class="flex items-center gap-2">
<c-lb.badge size="sm" style="soft" variant="primary">{{ member.role }}</c-lb.badge>
<c-lb.badge size="sm" style="outline">Member since {{ member.joined }}</c-lb.badge>
</div>
</div>
<c-vars :member="{}" :sessions="[]" />
<c-lb.card class="border border-base-300">
<c-lb.card.body class="gap-4 p-5">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Security</h2>
<c-lb.badge size="sm" style="soft" variant="success" icon="rmx.shield-check">2FA on</c-lb.badge>
</div>
<div class="flex flex-col gap-3">
{% for session in sessions %}
<div class="flex items-start gap-2">
<div class="mt-1 size-2 rounded-full shrink-0 {% if session.current %}bg-success{% else %}bg-base-300{% endif %}"></div>
<div class="text-xs min-w-0">
<div class="font-medium truncate">{{ session.device }}</div>
<div class="text-base-content/45 truncate">{{ session.note }}</div>
</div>
</div>
{% endfor %}
</div>
<c-lb.button btnStyle="outline" size="sm" class="w-full">Sign out other devices</c-lb.button>
<p class="text-xs text-base-content/45 border-t border-base-300 pt-3">
As {{ member.role|lower }} of {{ member.workspace }}, two-factor is enforced and
cannot be turned off.
</p>
</c-lb.card.body>
</c-lb.card>
Learn from this block
-
One object signal holds the preview URL and the file name. Nothing is sent anywhere: the file stays in the browser until the form is saved.
-
`URL.createObjectURL` turns the picked file into a local URL. Long handlers belong in a function, and `evt` is available inside any Datastar expression, so the attribute is left readable.
-
Replacing the object updates both readers at once — the preview and the file name. Remove sets it back to empty, and the initials avatar comes straight back.
-
The image reads the URL, the initials avatar shows while it is empty. The fallback is a themed `c-lb.avatar`, so this block ships no image asset at all and recolours with the theme.
Settings with sidebar nav
A settings shell with a vertical nav — switching section is one client signal, so the whole page is rendered once and nothing is fetched again
-
templates
-
pages
-
<c-lbr.signals id="settings-nav" $section="profile" />
<main class="container mx-auto max-w-6xl px-4 py-10 sm:py-14">
<div class="grid lg:grid-cols-[17rem_1fr] gap-8 lg:gap-12">
<c-sidebar-nav.rail :workspace=workspace :sections=sections />
<div>
<div data-show="$section === 'profile'">
<c-sidebar-nav.section-workspace :workspace=workspace :regions=regions />
</div>
<div data-show="$section === 'team'" style="display:none">
<c-sidebar-nav.section-team :workspace=workspace :members=members />
</div>
<div data-show="$section === 'billing'" style="display:none">
<c-sidebar-nav.section-billing :workspace=workspace :invoices=invoices />
</div>
<div data-show="$section === 'notifications'" style="display:none">
<c-sidebar-nav.section-notifications :notifications=notifications />
</div>
<div data-show="$section === 'security'" style="display:none">
<c-sidebar-nav.section-security :workspace=workspace />
</div>
</div>
</div>
</main>
<c-vars title="" desc="" actions="" />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-6 p-6 sm:p-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold tracking-tight">{{ title }}</h2>
{% if desc %}<p class="text-sm text-base-content/60 mt-1">{{ desc }}</p>{% endif %}
</div>
{% if actions %}
<div class="flex items-center gap-2">{{ actions }}</div>
{% endif %}
</div>
{{ slot }}
</c-lb.card.body>
</c-lb.card>
<c-vars :workspace="{}" :sections="[]" />
<aside class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ workspace.initials }}" bgColor="primary" />
<div class="min-w-0">
<div class="font-semibold truncate">{{ workspace.name }}</div>
<div class="text-xs font-mono text-base-content/50 truncate">arden.app/{{ workspace.slug }}</div>
</div>
</div>
<div class="rounded-box border border-base-300 p-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<c-lb.badge size="sm" style="soft" variant="primary" icon="rmx.bank-card">{{ workspace.plan }}</c-lb.badge>
<span class="text-xs text-base-content/50 tabular-nums">
{{ workspace.seats_used }}/{{ workspace.seats_total }} seats
</span>
</div>
<c-lb.progress variant="primary" value="{{ workspace.seats_pct }}" max="100" class="w-full h-1.5" />
<p class="text-xs text-base-content/45">
Thirteen seats left. Suspended members are not billed.
</p>
</div>
<c-lb.menu size="md" class="w-full p-0 gap-1">
{% for item in sections %}
<c-lb.menu.item
id="nav-{{ item.key }}"
class="cursor-pointer"
data-lbb-start
data-on:click="$section = '{{ item.key }}'"
data-class="{'menu-active': $section === '{{ item.key }}'}"
>
<c-lbi n="{{ item.icon }}" w="1em" h="1em" class="size-4 shrink-0" />
<span class="text-sm">{{ item.label }}</span>
</c-lb.menu.item>
{% endfor %}
</c-lb.menu>
<div class="flex items-start gap-3 rounded-box bg-base-200/60 p-4 mt-2">
<c-lb.avatar placeholder initials="HI" bgColor="accent" size="sm" />
<div class="text-xs">
<div class="font-medium">Something look wrong?</div>
<div class="text-base-content/50 mt-0.5">
Hana on support answers billing questions in under an hour.
</div>
</div>
</div>
</aside>
<c-vars :workspace="{}" :invoices="[]" />
<c-sidebar-nav.panel
title="Billing"
desc="What you pay Arden, and what Arden has billed on your behalf.">
<c-slot name="actions">
<c-lb.button btnStyle="outline" size="sm">Change plan</c-lb.button>
</c-slot>
<div class="grid sm:grid-cols-3 gap-4">
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Plan</div>
<div class="text-lg font-semibold mt-1">{{ workspace.plan }}</div>
<div class="text-xs text-base-content/50 tabular-nums mt-0.5">$449 / month</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Next invoice</div>
<div class="text-lg font-semibold tabular-nums mt-1">$449.00</div>
<div class="text-xs text-base-content/50 tabular-nums mt-0.5">1 August 2026</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">Outstanding</div>
<div class="text-lg font-semibold tabular-nums mt-1">$1,023.00</div>
<div class="text-xs text-base-content/50 mt-0.5">Two invoices open</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-xs uppercase tracking-wider text-base-content/40">Recent invoices</div>
<div class="overflow-x-auto border border-base-300 rounded-box">
<c-lb.table size="sm" class="w-full">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Issued</th>
<th class="text-right">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td class="font-medium tabular-nums whitespace-nowrap">{{ invoice.number }}</td>
<td>{{ invoice.customer }}</td>
<td class="tabular-nums whitespace-nowrap text-base-content/60">{{ invoice.issued }}</td>
<td class="text-right tabular-nums font-medium whitespace-nowrap">
USD {{ invoice.amount }}
</td>
<td>
<c-lb.badge size="sm" style="soft" variant="{{ invoice.tone }}">
{{ invoice.status }}
</c-lb.badge>
</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
</div>
</div>
</c-sidebar-nav.panel>
<c-vars :notifications="[]" />
<c-sidebar-nav.panel
title="Notifications"
desc="Arden emails you when the revenue graph moves. Nothing else.">
<div class="rounded-box border border-base-300 divide-y divide-base-300">
{% for item in notifications %}
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">{{ item.label }}</div>
<div class="text-xs text-base-content/55 mt-0.5">{{ item.note }}</div>
</div>
<c-lb.toggle variant="primary" :checked=item.on />
</div>
{% endfor %}
</div>
<p class="text-xs text-base-content/45">
The digest goes to every active member. Alerts go only to admins and the billing role.
</p>
</c-sidebar-nav.panel>
<c-vars :workspace="{}" />
<c-sidebar-nav.panel
title="Security"
desc="Who can get in, and what {{ workspace.name }} keeps a record of.">
<div class="rounded-box border border-base-300 divide-y divide-base-300">
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">Two-factor authentication</div>
<div class="text-xs text-base-content/55 mt-0.5">
Required for the owner, admin and billing roles.
</div>
</div>
<c-lb.badge size="sm" style="soft" variant="success" icon="rmx.shield-check">Enforced</c-lb.badge>
</div>
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">SAML single sign-on</div>
<div class="text-xs text-base-content/55 mt-0.5">
Included on {{ workspace.plan }}. Okta, Entra ID and Google Workspace.
</div>
</div>
<c-lb.button btnStyle="outline" size="sm">Configure</c-lb.button>
</div>
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">Audit log</div>
<div class="text-xs text-base-content/55 mt-0.5 tabular-nums">
18,942 events kept for 24 months. Exportable as CSV.
</div>
</div>
<c-lb.button btnStyle="ghost" size="sm" icon="rmx.download">Export</c-lb.button>
</div>
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">Session timeout</div>
<div class="text-xs text-base-content/55 mt-0.5">
Members are signed out after 14 days of inactivity.
</div>
</div>
<c-lb.badge size="sm" style="outline" class="tabular-nums">14 days</c-lb.badge>
</div>
</div>
<div class="flex items-start gap-2 text-xs text-base-content/50">
<c-lbi n="rmx.information" w="1em" h="1em" class="size-4 mt-0.5 shrink-0" />
<span>
Ilse Bergman was suspended on 11 May 2026 and keeps no access. Suspended members
still appear in the audit log.
</span>
</div>
</c-sidebar-nav.panel>
<c-vars :workspace="{}" :members="[]" />
<c-sidebar-nav.panel
title="Team"
desc="Everyone with a seat on {{ workspace.name }}.">
<c-slot name="actions">
<c-lb.button variant="primary" size="sm" icon="rmx.user-add">Invite</c-lb.button>
</c-slot>
<div class="rounded-box border border-base-300 divide-y divide-base-300">
{% for member in members %}
<div class="flex items-center gap-4 p-4">
<c-lb.avatar placeholder initials="{{ member.initials }}" bgColor="{{ member.tone }}" size="sm" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{{ member.name }}</div>
<div class="text-xs text-base-content/50 truncate">{{ member.title }} · {{ member.email }}</div>
</div>
<c-lb.badge size="sm" style="soft" variant="{{ member.tone }}">{{ member.role }}</c-lb.badge>
<div class="hidden sm:block text-xs text-base-content/45 tabular-nums w-28 text-right">
{{ member.last_seen }}
</div>
</div>
{% endfor %}
</div>
<div class="flex items-center justify-between gap-4 text-xs text-base-content/50">
<span class="tabular-nums">
{{ workspace.seats_used }} of {{ workspace.seats_total }} seats used on {{ workspace.plan }}
</span>
<span>Two invitations are still pending.</span>
</div>
</c-sidebar-nav.panel>
<c-vars :workspace="{}" :regions="[]" />
<c-sidebar-nav.panel
title="Workspace"
desc="What your team signs in to, and where their revenue data is stored.">
<c-slot name="actions">
<c-lb.button variant="primary" size="sm">Save changes</c-lb.button>
</c-slot>
<div class="grid sm:grid-cols-2 gap-5">
<div>
<label class="label label-text text-sm font-medium mb-1">Workspace name</label>
<c-lb.input type="text" class="w-full" value="{{ workspace.name }}" />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">
Address <span class="text-base-content/40 font-normal">(arden.app/…)</span>
</label>
<c-lb.input type="text" class="w-full font-mono" value="{{ workspace.slug }}" />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">Billing email</label>
<c-lb.input type="email" class="w-full" value="{{ workspace.billing_email }}" />
</div>
<div>
<label class="label label-text text-sm font-medium mb-1">Data region</label>
<c-lb.select class="w-full">
{% for value, label in regions %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</div>
</div>
<div class="rounded-box border border-base-300 divide-y divide-base-300">
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">Reporting currency</div>
<div class="text-xs text-base-content/55 mt-0.5">
Every figure in Arden is shown in {{ workspace.currency }}, converted daily at the ECB rate.
</div>
</div>
<c-lb.badge size="sm" style="outline">{{ workspace.currency }}</c-lb.badge>
</div>
<div class="flex items-center justify-between gap-4 p-4">
<div>
<div class="text-sm font-medium">Close the workspace</div>
<div class="text-xs text-base-content/55 mt-0.5">
Exports everything to CSV, then deletes it after 30 days.
</div>
</div>
<c-lb.button variant="error" btnStyle="ghost" size="sm">Close</c-lb.button>
</div>
</div>
</c-sidebar-nav.panel>
Learn from this block
-
A string. That is the entire state of this settings shell — declared in prop mode, with no schema and no view behind it.
-
Clicking a nav item assigns the signal, and `data-class` marks the active row from the same value. Open the network tab and click every section: nothing is sent.
-
Settings pages are small. Rendering all five sections once and showing one is faster than five round-trips, and it means a half-typed field survives a trip to Billing and back.
-
Navigation is a client concern; saving is not. Wrap this button in a `c-lbr.post` and it becomes an ordinary Django view — which is exactly what the workspace anchor does.
Team members
A member list with editable roles and an invite row — every role dropdown binds to a client signal, and the unsaved-changes bar is derived from the difference
-
components
-
team_members
-
-
templates
-
pages
-
<c-lbr.signals
id="team-roles"
:$roles=roles
:$saved=saved
:$rolelabels=rolelabels
:$invite=invite />
<main class="container mx-auto max-w-4xl px-4 py-10 sm:py-14 flex flex-col gap-6">
<c-team-members.header :workspace=workspace />
<c-team-members.invite :role_options=role_options />
<c-team-members.roster :members=members :role_options=role_options />
<c-team-members.pending :pending=pending :suspended=suspended />
<c-team-members.legend />
<c-team-members.savebar />
</main>
<c-vars :workspace="{}" />
<div class="flex flex-col gap-5 border-b border-base-300 pb-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Team</h1>
<p class="text-sm text-base-content/60 mt-1">
Everyone with a seat on {{ workspace.name }}, and what each of them can do.
</p>
</div>
<c-lb.badge style="soft" variant="primary" icon="rmx.bank-card">{{ workspace.plan }}</c-lb.badge>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-xs">
<span class="text-base-content/55">Seats in use</span>
<span class="tabular-nums font-medium">
{{ workspace.seats_used }} of {{ workspace.seats_total }}
</span>
</div>
<c-lb.progress variant="primary" value="{{ workspace.seats_pct }}" max="100" class="w-full h-2" />
<p class="text-xs text-base-content/45">
Invited members hold a seat from the moment they accept. Suspended members do not.
</p>
</div>
</div>
<c-vars :role_options="[]" />
<c-lb.card class="border border-base-300 bg-base-200/40">
<c-lb.card.body class="gap-3 p-5">
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
<div class="flex-1">
<label class="label label-text text-sm font-medium mb-1">Invite by email</label>
<c-lb.input
data-lbb-start
id="tm-invite-email"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$invite.email" />
</div>
<div class="sm:w-44">
<label class="label label-text text-sm font-medium mb-1">Role</label>
<c-lb.select id="tm-invite-role" class="w-full" bind="$invite.role">
{% for value, label in role_options %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</div>
<c-lb.button variant="primary" icon="rmx.user-add">Send invite</c-lb.button>
</div>
<p id="tm-invite-preview" class="text-xs text-base-content/50"
data-text="($invite.email || 'They') + ' will join as a ' + $rolelabels[$invite.role] + '.'">
They will join as a Member.
</p>
</c-lb.card.body>
</c-lb.card>
<div class="rounded-box bg-base-200/60 p-5 flex flex-col gap-3">
<span class="text-xs uppercase tracking-wider text-base-content/40">What each role can do</span>
<div class="grid sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Owner</span>
<span class="text-base-content/55">Everything, including closing the workspace.</span>
</div>
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Admin</span>
<span class="text-base-content/55">Invites people and changes billing.</span>
</div>
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Member</span>
<span class="text-base-content/55">Full access to customers, invoices and forecasts.</span>
</div>
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Billing</span>
<span class="text-base-content/55">Invoices and payment methods only.</span>
</div>
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Viewer</span>
<span class="text-base-content/55">Read-only dashboards. Cannot export.</span>
</div>
<div class="flex gap-2">
<span class="font-medium w-16 shrink-0">Anyone</span>
<span class="text-base-content/55">Sees only the customers their team owns.</span>
</div>
</div>
</div>
<c-vars :pending="[]" :suspended="[]" />
<div class="grid sm:grid-cols-2 gap-4">
<div class="rounded-box border border-base-300 p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wider text-base-content/40">Invitations pending</span>
<span class="text-xs text-base-content/40 tabular-nums">{{ pending|length }}</span>
</div>
{% for person in pending %}
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ person.initials }}" bgColor="warning" size="sm" />
<div class="text-xs min-w-0 flex-1">
<div class="font-medium truncate">{{ person.name }}</div>
<div class="text-base-content/45 truncate">{{ person.invited }} · {{ person.role }}</div>
</div>
<c-lb.button btnStyle="ghost" size="xs">Resend</c-lb.button>
</div>
{% empty %}
<p class="text-xs text-base-content/50">No invitations are outstanding.</p>
{% endfor %}
</div>
<div class="rounded-box border border-base-300 p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wider text-base-content/40">Suspended</span>
<span class="text-xs text-base-content/40 tabular-nums">{{ suspended|length }}</span>
</div>
{% for person in suspended %}
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ person.initials }}" bgColor="neutral" size="sm" />
<div class="text-xs min-w-0 flex-1">
<div class="font-medium truncate">{{ person.name }}</div>
<div class="text-base-content/45 truncate">{{ person.note }}</div>
</div>
<c-lb.button btnStyle="ghost" size="xs">Restore</c-lb.button>
</div>
{% empty %}
<p class="text-xs text-base-content/50">Nobody is suspended.</p>
{% endfor %}
</div>
</div>
<c-vars :members="[]" :role_options="[]" />
<div class="rounded-box border border-base-300 divide-y divide-base-300">
{% for member in members %}
<div
id="row-{{ member.key }}"
class="flex flex-wrap items-center gap-4 p-4"
data-class="{'bg-warning/5': $roles.{{ member.key }} !== $saved.{{ member.key }}}"
>
<c-lb.avatar placeholder initials="{{ member.initials }}" bgColor="{{ member.tone }}" size="sm" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{{ member.name }}</div>
<div class="text-xs text-base-content/50 truncate">{{ member.title }} · {{ member.email }}</div>
</div>
<div class="hidden lg:block text-xs text-base-content/45 tabular-nums w-28 text-right">
{{ member.last_seen }}
</div>
<c-lb.badge size="sm" style="outline" variant="{{ member.status_tone }}">
{{ member.status }}
</c-lb.badge>
<c-lb.select
id="role-{{ member.key }}"
size="sm"
class="w-36"
bind="$roles.{{ member.key }}"
>
{% for value, label in role_options %}
<option value="{{ value }}" {% if value == member.role %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</c-lb.select>
<span
id="changed-{{ member.key }}"
class="text-xs text-warning font-medium w-16"
data-show="$roles.{{ member.key }} !== $saved.{{ member.key }}"
style="display:none"
>Changed</span>
</div>
{% endfor %}
</div>
<div
id="tm-savebar"
class="sticky bottom-4 flex flex-wrap items-center justify-between gap-4
rounded-box border border-warning/40 bg-base-100 shadow-lg p-4"
data-show="Object.keys($roles).filter(k => $roles[k] !== $saved[k]).length > 0"
style="display:none"
>
<div class="flex items-center gap-3">
<c-lbi n="rmx.error-warning" w="1em" h="1em" class="size-5 text-warning shrink-0" />
<div class="text-sm">
<span class="font-medium tabular-nums">
<span id="tm-pending"
data-text="Object.keys($roles).filter(k => $roles[k] !== $saved[k]).length">0</span>
role change<span data-show="Object.keys($roles).filter(k => $roles[k] !== $saved[k]).length !== 1"
style="display:none">s</span>
</span>
<span class="text-base-content/55">not saved yet.</span>
</div>
</div>
<div class="flex items-center gap-2">
<c-lb.button
id="tm-discard"
type="button"
btnStyle="ghost"
size="sm"
data-on:click="$roles = Object.assign({}, $saved)"
>Discard</c-lb.button>
<c-lb.button id="tm-save" type="button" variant="primary" size="sm">Save roles</c-lb.button>
</div>
</div>
Learn from this block
-
`roles` is what the dropdowns write to. `saved` is what the server last knew and is never mutated.
-
One select per member, one path each. The server renders the current role as `selected`, so the list is correct before the runtime loads and the binding takes over from there.
-
A comparison between the two signals, rendered as a theme-token tint. Nothing tracks which rows changed — the difference *is* the state.
-
Count the keys that differ. Change a dropdown and the bar appears; change it back and it goes away, with no request in between.
-
The email, the role, and a label lookup held in the signal bag. Putting the labels in signals is the same trick the summary wizard uses for prices: it turns a server round-trip into an expression.
Workspace settings
Tabbed workspace settings where saving one section re-renders only that section
import re
from datastar_py.consts import ElementPatchMode
from django.shortcuts import redirect
from django.utils import timezone
from labb.contrib.blocks import lm, render_page
from labb.reactivity import SSEResponse, patch_component
from labb.signals import Signals, Str
LbInvoice = lm("LbInvoice")
LbMember = lm("LbMember")
LbPlan = lm("LbPlan")
LbWorkspace = lm("LbWorkspace")
INDEX = "lb/settings/workspace/pages/index.html"
INNER = ElementPatchMode.INNER
TITLE = "Workspace settings"
REGIONS = [
("eu-west-1", "Europe (Ireland)"),
("us-east-1", "US East (Virginia)"),
("ap-south-1", "Asia Pacific (Mumbai)"),
]
INVITE_ROLES = [
("admin", "Admin — can change billing and members"),
("member", "Member — full access to customers and invoices"),
("billing", "Billing — invoices and payment methods only"),
("viewer", "Viewer — read-only"),
]
ROLE_VARIANTS = {
"owner": "primary",
"admin": "secondary",
"billing": "accent",
"member": "neutral",
"viewer": "neutral",
}
STATUS_VARIANTS = {"active": "success", "invited": "warning", "suspended": "error"}
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
MIN_SLUG = 3
class ProfileSignals(Signals):
name = Str(path="profile.name", default="")
slug = Str(path="profile.slug", default="")
email = Str(path="profile.email", default="")
region = Str(path="profile.region", default="")
class InviteSignals(Signals):
email = Str(path="invite.email", default="")
role = Str(path="invite.role", default="member")
# --- data ------------------------------------------------------------------
def _workspace():
return LbWorkspace.objects.select_related("plan").first()
def _initials(name):
parts = [p for p in name.split() if p]
return "".join(p[0] for p in parts[:2]).upper() or "?"
def _members(workspace):
members = list(workspace.members.all())
for m in members:
m.initials = _initials(m.name)
m.role_variant = ROLE_VARIANTS.get(m.role, "neutral")
m.status_variant = STATUS_VARIANTS.get(m.status, "neutral")
return members
def _seats_used(members):
"""A suspended member is not billed, so they do not hold a seat."""
return sum(1 for m in members if m.status != "suspended")
def _looks_like_email(value):
return (
"@" in value and "." in value.split("@")[-1] and len(value.split("@")[-1]) > 3
)
# --- validation ------------------------------------------------------------
def _profile_errors(s, workspace, strict=False):
"""Gentle while typing, strict on save."""
errors = {}
if s.name:
if len(s.name.strip()) < 2:
errors["name"] = "Workspace names need at least two characters."
elif strict:
errors["name"] = "A workspace name is required."
if s.slug:
if not SLUG_RE.match(s.slug):
errors["slug"] = "Lowercase letters, numbers and single hyphens only."
elif len(s.slug) < MIN_SLUG:
errors["slug"] = f"At least {MIN_SLUG} characters ({len(s.slug)} so far)."
elif LbWorkspace.objects.exclude(pk=workspace.pk).filter(slug=s.slug).exists():
errors["slug"] = "Another workspace already uses that address."
elif strict:
errors["slug"] = "A workspace address is required."
if s.email:
if not _looks_like_email(s.email):
errors["email"] = "Enter a valid billing email."
elif strict:
errors["email"] = "Invoices need somewhere to go."
return errors
def _invite_errors(s, workspace, strict=False):
errors = {}
if s.email:
if not _looks_like_email(s.email):
errors["email"] = "Enter a valid work email."
else:
existing = LbMember.objects.filter(email__iexact=s.email).first()
if existing is not None:
errors["email"] = f"{existing.name} is already on this workspace."
elif strict:
errors["email"] = "Enter the email of the person you want to invite."
return errors
# --- section props ---------------------------------------------------------
#
# One dict per section, shared by the full-page render and the scoped patch, so
# a zone can never drift between the two.
def _profile_signals(request, workspace):
s = ProfileSignals(request)
if not request.signals:
s.name = workspace.name
s.slug = workspace.slug
s.email = workspace.billing_email
s.region = workspace.region
return s
def _profile_props(request, workspace, errors=None):
return {
"workspace": workspace,
"signals": _profile_signals(request, workspace),
"errors": errors or {},
"regions": REGIONS,
}
def _team_props(request, workspace, errors=None):
members = _members(workspace)
seats_used = _seats_used(members)
# A workspace can sit on no plan (LbWorkspace.plan is nullable): no plan, no seats.
seats_total = workspace.plan.seats_included if workspace.plan else 0
return {
"workspace": workspace,
"members": members,
"seats_used": seats_used,
"seats_total": seats_total,
"seats_left": max(0, seats_total - seats_used),
"seats_full": seats_used >= seats_total,
"seats_pct": min(100, round(seats_used * 100 / seats_total))
if seats_total
else 0,
"plan": workspace.plan,
"signals": InviteSignals(request),
"errors": errors or {},
"roles": INVITE_ROLES,
}
def _billing_props(request, workspace, failed=""):
invoices = list(
LbInvoice.objects.filter(customer__workspace=workspace).select_related(
"customer"
)[:5]
)
for inv in invoices:
inv.status_variant = {
"paid": "success",
"open": "info",
"overdue": "error",
}.get(inv.status, "neutral")
outstanding = sum(
inv.amount
for inv in LbInvoice.objects.filter(
customer__workspace=workspace, status__in=("open", "overdue")
)
)
plans = list(LbPlan.objects.all())
for p in plans:
p.is_current = p.pk == workspace.plan_id
return {
"workspace": workspace,
"plan": workspace.plan,
"plans": plans,
"invoices": invoices,
"outstanding": f"{outstanding:,.2f}",
"billing_contacts": [m for m in workspace.members.filter(role="billing")],
"failed": failed,
}
def _context(request, workspace):
if workspace is None:
return {"workspace": None}
return {
"workspace": workspace,
"profile_signals": _profile_signals(request, workspace),
"invite_signals": InviteSignals(request),
"profile": _profile_props(request, workspace),
"team": _team_props(request, workspace),
"billing": _billing_props(request, workspace),
}
# --- patches ---------------------------------------------------------------
def _patch_profile(request, workspace, errors=None):
return patch_component(
request,
"@section-profile",
"workspace.profile",
mode=INNER,
**_profile_props(request, workspace, errors),
)
def _patch_team(request, workspace, errors=None):
return patch_component(
request,
"@section-team",
"workspace.team",
mode=INNER,
**_team_props(request, workspace, errors),
)
def _patch_billing(request, workspace, failed=""):
return patch_component(
request,
"@section-billing",
"workspace.billing",
mode=INNER,
**_billing_props(request, workspace, failed),
)
def _patch_toast(request, message, detail=""):
return patch_component(
request,
"@toast",
"workspace.toast",
mode=INNER,
message=message,
detail=detail,
)
# --- views -----------------------------------------------------------------
def index(request):
return render_page(request, INDEX, _context(request, _workspace()), title=TITLE)
def profile_validate(request):
workspace = _workspace()
if workspace is None:
return redirect("block_settings_workspace:index")
s = _profile_signals(request, workspace)
errors = _profile_errors(s, workspace)
return SSEResponse([_patch_profile(request, workspace, errors)])
def profile_save(request):
workspace = _workspace()
if request.method != "POST" or workspace is None:
return redirect("block_settings_workspace:index")
s = _profile_signals(request, workspace)
errors = _profile_errors(s, workspace, strict=True)
if errors:
return SSEResponse([_patch_profile(request, workspace, errors)])
workspace.name = s.name.strip()
workspace.slug = s.slug.strip()
workspace.billing_email = s.email.strip()
workspace.region = s.region or workspace.region
workspace.save()
return SSEResponse(
[
_patch_profile(request, workspace),
_patch_toast(
request,
"Profile saved",
f"{workspace.name} · arden.app/w/{workspace.slug}",
),
]
)
def invite_validate(request):
workspace = _workspace()
if workspace is None:
return redirect("block_settings_workspace:index")
s = InviteSignals(request)
return SSEResponse([_patch_team(request, workspace, _invite_errors(s, workspace))])
def invite_save(request):
workspace = _workspace()
if request.method != "POST" or workspace is None:
return redirect("block_settings_workspace:index")
s = InviteSignals(request)
errors = _invite_errors(s, workspace, strict=True)
props = _team_props(request, workspace)
if workspace.plan is None:
errors["email"] = "This workspace is not on a plan. Choose one to add seats."
elif props["seats_full"]:
errors["email"] = (
f"All {props['seats_total']} seats on {workspace.plan.name} are in use. "
"Change plan to add more."
)
if errors:
return SSEResponse([_patch_team(request, workspace, errors)])
email = s.email.strip()
member = LbMember.objects.create(
workspace=workspace,
name=email.split("@")[0].replace(".", " ").title(),
email=email,
title="",
role=s.role,
status="invited",
joined_on=timezone.now().date(),
last_active_at=None,
)
s.email = ""
return SSEResponse(
[
s.patch("email"),
_patch_team(request, workspace),
_patch_toast(
request,
"Invite sent",
f"{member.email} joins as {member.get_role_display()}.",
),
]
)
def plan_change(request, pk):
workspace = _workspace()
if request.method != "POST" or workspace is None:
return redirect("block_settings_workspace:index")
plan = LbPlan.objects.filter(pk=pk).first()
if plan is None or plan.pk == workspace.plan_id:
return SSEResponse([_patch_billing(request, workspace)])
seats_used = _seats_used(_members(workspace))
if seats_used > plan.seats_included:
failed = (
f"{plan.name} includes {plan.seats_included} seats and {seats_used} are in use. "
"Remove members first, or stay on your current plan."
)
return SSEResponse([_patch_billing(request, workspace, failed)])
workspace.plan = plan
workspace.save()
workspace = _workspace()
# The seat allowance shown in the team section comes from the plan, so that
# zone is genuinely affected too — patch every zone the change touches, and
# only those.
return SSEResponse(
[
_patch_billing(request, workspace),
_patch_team(request, workspace),
_patch_toast(request, "Plan changed", f"You are now on {plan.name}."),
]
)
from django.urls import path
from . import views
app_name = "block_settings_workspace"
urlpatterns = [
path("", views.index, name="index"),
path("profile/validate/", views.profile_validate, name="profile_validate"),
path("profile/save/", views.profile_save, name="profile_save"),
path("team/validate/", views.invite_validate, name="invite_validate"),
path("team/invite/", views.invite_save, name="invite_save"),
path("billing/plan/<int:pk>/", views.plan_change, name="plan_change"),
]
{% if workspace %}
<c-lbr.signals id="ws-profile" :schema=profile_signals />
<c-lbr.signals id="ws-invite" :schema=invite_signals />
<main class="container mx-auto max-w-5xl px-4 py-10 sm:py-14">
<c-workspace.header />
<c-lb.tabs name="ws_settings" style="lift" class="mt-8" data-lbb-start>
<c-lb.tabs.content
name="ws_settings"
title="Profile"
icon="rmx.building"
checked
class="border-base-300 bg-base-100">
<c-lbr.target name="section-profile" tag="div">
<c-workspace.profile
:workspace=profile.workspace
:signals=profile.signals
:errors=profile.errors
:regions=profile.regions />
</c-lbr.target>
</c-lb.tabs.content>
<c-lb.tabs.content
name="ws_settings"
title="Team"
icon="rmx.team"
class="border-base-300 bg-base-100">
<c-lbr.target name="section-team" tag="div">
<c-workspace.team
:members=team.members
:seats_used=team.seats_used
:seats_total=team.seats_total
:seats_left=team.seats_left
:seats_full=team.seats_full
:seats_pct=team.seats_pct
:plan=team.plan
:signals=team.signals
:errors=team.errors
:roles=team.roles />
</c-lbr.target>
</c-lb.tabs.content>
<c-lb.tabs.content
name="ws_settings"
title="Billing"
icon="rmx.bank-card"
class="border-base-300 bg-base-100">
<c-lbr.target name="section-billing" tag="div">
<c-workspace.billing
:workspace=billing.workspace
:plan=billing.plan
:plans=billing.plans
:invoices=billing.invoices
:outstanding=billing.outstanding
:billing_contacts=billing.billing_contacts
:failed=billing.failed />
</c-lbr.target>
</c-lb.tabs.content>
</c-lb.tabs>
<c-lbr.target name="toast" tag="div"></c-lbr.target>
</main>
{% else %}
<main class="container mx-auto max-w-2xl px-4 py-20">
<c-lb.alert variant="info" alertStyle="soft" icon="rmx.information">
This workspace has no data yet. Load the sample workspace to see its settings.
</c-lb.alert>
</main>
{% endif %}
<c-vars :workspace="None" :plan="None" :plans="[]" :invoices="[]" outstanding="0.00" :billing_contacts="[]" failed="" />
<c-workspace.section
title="Plan and billing"
desc="What you pay Arden, and what Arden has billed on your behalf.">
<c-slot name="actions">
<c-lb.badge style="soft" variant="primary" icon="rmx.bank-card">{{ plan.name }}</c-lb.badge>
</c-slot>
{% if failed %}
<c-lb.alert variant="error" alertStyle="soft" icon="rmx.error-warning">
{{ failed }}
</c-lb.alert>
{% endif %}
<div class="grid lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 flex flex-col gap-5">
<div class="flex flex-col gap-2">
<div class="text-xs uppercase tracking-wider text-base-content/40">Your plan</div>
{% for option in plans %}
<c-workspace.plan-option
:option=option
:current=option.is_current
currency="{{ workspace.currency }}" />
{% endfor %}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-end justify-between">
<div class="text-xs uppercase tracking-wider text-base-content/40">
Recent invoices
</div>
<div class="text-xs text-base-content/50 tabular-nums">
{{ workspace.currency }} {{ outstanding }} outstanding
</div>
</div>
{% if invoices %}
<div class="overflow-x-auto border border-base-300 rounded-box">
<c-lb.table size="sm" class="w-full">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Issued</th>
<th class="text-right">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr id="invoice-{{ invoice.pk }}">
<td class="font-medium tabular-nums whitespace-nowrap">{{ invoice.number }}</td>
<td class="truncate">{{ invoice.customer.company }}</td>
<td class="tabular-nums whitespace-nowrap text-base-content/60">
{{ invoice.issued_on|date:"d/m/Y" }}
</td>
<td class="text-right tabular-nums font-medium whitespace-nowrap">
{{ invoice.currency }} {{ invoice.amount|floatformat:2 }}
</td>
<td>
<c-lb.badge size="sm" style="soft" variant="{{ invoice.status_variant }}">
{{ invoice.get_status_display }}
</c-lb.badge>
</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<div class="border border-dashed border-base-300 rounded-box p-8 text-center">
<p class="font-medium">No invoices yet.</p>
<p class="text-sm text-base-content/60 mt-1">
The first one appears the day after your first customer renews.
</p>
</div>
{% endif %}
</div>
</div>
<div class="flex flex-col gap-5">
<c-lb.card class="border border-base-300 bg-base-200/40 h-fit">
<c-lb.card.body class="p-5 gap-3">
<div class="text-xs uppercase tracking-wider text-base-content/40">
{{ plan.name }}
</div>
<div class="flex items-baseline gap-1">
<span class="text-3xl font-semibold tabular-nums">
{{ workspace.currency }} {{ plan.price_monthly|floatformat:0 }}
</span>
<span class="text-sm text-base-content/50">/ month</span>
</div>
<p class="text-xs text-base-content/60 leading-relaxed">{{ plan.tagline }}</p>
<ul class="flex flex-col gap-2 border-t border-base-300 pt-3">
{% for feature in plan.features|slice:":4" %}
<li class="flex items-start gap-2 text-sm">
<c-lbi n="rmx.check" w="1em" h="1em" class="size-4 mt-0.5 text-success shrink-0" />
<span>{{ feature }}</span>
</li>
{% endfor %}
</ul>
<p class="text-xs text-base-content/50 tabular-nums border-t border-base-300 pt-3">
Yearly: {{ workspace.currency }} {{ plan.price_yearly|floatformat:0 }} — two months free.
</p>
</c-lb.card.body>
</c-lb.card>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wider text-base-content/40">Billing contacts</div>
{% for contact in billing_contacts %}
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ contact.name|slice:':1' }}" bgColor="accent" size="sm" />
<div class="text-sm min-w-0">
<div class="font-medium truncate">{{ contact.name }}</div>
<div class="text-xs text-base-content/50 truncate">{{ contact.email }}</div>
</div>
</div>
{% empty %}
<p class="text-sm text-base-content/50">
No one holds the billing role. Invoices go to the workspace email only.
</p>
{% endfor %}
</div>
</div>
</div>
</c-workspace.section>
<c-vars label="" hint="" error="" required validateTo="" on="input__debounce.500ms" />
<c-lbr.post :to=validateTo tag="div" on="{{ on }}" class="flex flex-col">
<label class="label label-text text-sm font-medium mb-1">
{{ label }}{% if required %} <span class="text-error">*</span>{% endif %}
{% if hint %}<span class="text-base-content/40 font-normal">{{ hint }}</span>{% endif %}
</label>
{{ slot }}
{% if error %}
<p class="text-error text-xs mt-1">{{ error }}</p>
{% endif %}
</c-lbr.post>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<c-arden.mark size="sm" />
<span class="text-sm font-semibold tracking-tight">Arden</span>
<c-lb.badge size="sm" style="soft" icon="rmx.shield-check">Owner access</c-lb.badge>
</div>
<h1 class="mt-3 text-3xl font-semibold tracking-tight">Workspace settings</h1>
<p class="text-base-content/60 max-w-xl">
Your workspace profile, the people in it, and the plan behind it.
Each section saves on its own — nothing else on the page moves.
</p>
</div>
<c-vars :member="None" />
<tr id="member-{{ member.pk }}">
<td>
<div class="flex items-center gap-3">
<c-lb.avatar
placeholder
initials="{{ member.initials }}"
bgColor="{{ member.role_variant }}"
size="sm" />
<div class="min-w-0">
<div class="font-medium truncate">{{ member.name }}</div>
<div class="text-xs text-base-content/50 truncate">
{{ member.title|default:"No title" }} · {{ member.email }}
</div>
</div>
</div>
</td>
<td>
<c-lb.badge size="sm" style="soft" variant="{{ member.role_variant }}">
{{ member.get_role_display }}
</c-lb.badge>
</td>
<td>
<c-lb.badge size="sm" style="outline" variant="{{ member.status_variant }}">
{{ member.get_status_display }}
</c-lb.badge>
</td>
<td class="text-sm tabular-nums text-base-content/60 whitespace-nowrap">
{% if member.last_active_at %}
{{ member.last_active_at|timesince }} ago
{% else %}
<span class="text-base-content/40">Never signed in</span>
{% endif %}
</td>
</tr>
<c-vars :option="None" :current="False" currency="USD" />
<div id="plan-{{ option.slug }}"
class="flex items-center justify-between gap-4 p-4 rounded-box border
{% if current %}border-primary bg-primary/5{% else %}border-base-300{% endif %}">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium">{{ option.name }}</span>
{% if current %}
<c-lb.badge size="xs" variant="primary">Current</c-lb.badge>
{% endif %}
</div>
<p class="text-xs text-base-content/50 mt-1">
{{ option.seats_included }} seats · {{ option.tagline }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div class="text-right">
<div class="font-semibold tabular-nums">{{ currency }} {{ option.price_monthly|floatformat:0 }}</div>
<div class="text-xs text-base-content/40">per month</div>
</div>
{% if not current %}
<c-lbr.post to="block_settings_workspace:plan_change" pk=option.pk tag="div" on="click">
<c-lb.button type="button" size="sm" btnStyle="outline">Switch</c-lb.button>
</c-lbr.post>
{% endif %}
</div>
</div>
<c-vars :workspace="None" :signals="{}" :errors="{}" :regions="[]" />
<c-workspace.section
title="{{ workspace.name }}"
desc="Created {{ workspace.created_at|date:'j M Y' }} · billed in {{ workspace.currency }}">
<c-slot name="actions">
<c-lbr.post to="block_settings_workspace:profile_save" tag="div" on="click">
<c-lb.button type="button" variant="primary" size="sm">Save changes</c-lb.button>
</c-lbr.post>
</c-slot>
<div class="grid lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 grid sm:grid-cols-2 gap-5">
<c-workspace.field
label="Workspace name"
required
:error=errors.name
validateTo="block_settings_workspace:profile_validate">
<c-lb.input
class="w-full"
placeholder="Beacon Labs"
autocomplete="off"
:bind=signals.fields.name />
</c-workspace.field>
<c-workspace.field
label="Billing email"
hint="— invoices go here"
required
:error=errors.email
validateTo="block_settings_workspace:profile_validate">
<c-lb.input
type="email"
class="w-full"
placeholder="[email protected]"
autocomplete="off"
:bind=signals.fields.email />
</c-workspace.field>
<c-workspace.field
label="Workspace address"
required
:error=errors.slug
validateTo="block_settings_workspace:profile_validate">
<c-lb.input
class="w-full"
placeholder="beacon-labs"
autocomplete="off"
:bind=signals.fields.slug />
<p class="text-xs text-base-content/50 mt-1">
arden.app/w/<span class="font-medium text-base-content/80" data-text="$profile.slug"></span>
</p>
</c-workspace.field>
<c-workspace.field
label="Data residency"
:error=errors.region
on="change"
validateTo="block_settings_workspace:profile_validate">
<c-lb.select class="w-full" :bind=signals.fields.region>
{% for value, label in regions %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
<p class="text-xs text-base-content/50 mt-1">
Customer records never leave this region.
</p>
</c-workspace.field>
</div>
<c-lb.card class="border border-base-300 bg-base-200/40 h-fit">
<c-lb.card.body class="p-5 gap-4">
<div class="text-xs uppercase tracking-wider text-base-content/40">
Workspace at a glance
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/50">Workspace ID</span>
<span class="font-medium tabular-nums">ws_{{ workspace.pk }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/50">Created</span>
<span class="font-medium tabular-nums">{{ workspace.created_at|date:"d/m/Y" }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/50">Currency</span>
<span class="font-medium">{{ workspace.currency }}</span>
</div>
<p class="text-xs text-base-content/50 leading-relaxed border-t border-base-300 pt-3">
Renaming a workspace changes its address. Old links keep working
for 30 days, then stop.
</p>
</c-lb.card.body>
</c-lb.card>
</div>
</c-workspace.section>
<c-vars :seats_used=0 :seats_total=0 :seats_left=0 :seats_pct=0 :seats_full="False" :plan="None" />
<c-lb.card class="border border-base-300 bg-base-200/40">
<c-lb.card.body class="p-5 gap-3">
<div class="flex items-end justify-between gap-4">
<div>
<div class="text-xs uppercase tracking-wider text-base-content/40">Seats</div>
<div class="mt-1 text-2xl font-semibold tabular-nums">
{{ seats_used }} <span class="text-base-content/40">/ {{ seats_total }}</span>
</div>
</div>
<c-lb.badge style="soft" variant="{% if seats_full %}warning{% else %}neutral{% endif %}">
{{ plan.name }}
</c-lb.badge>
</div>
<c-lb.progress
variant="{% if seats_full %}warning{% else %}primary{% endif %}"
value="{{ seats_pct }}"
max="100"
class="w-full" />
<p class="text-xs text-base-content/50">
{% if seats_full %}
Every seat is in use. Change plan to invite anyone else.
{% else %}
{{ seats_left }} seat{{ seats_left|pluralize }} left. Suspended members do not hold a seat.
{% endif %}
</p>
</c-lb.card.body>
</c-lb.card>
<c-vars title="" desc="" actions="" />
<div class="p-6 sm:p-8 flex flex-col gap-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold tracking-tight">{{ title }}</h2>
{% if desc %}<p class="text-sm text-base-content/60 mt-1">{{ desc }}</p>{% endif %}
</div>
{% if actions %}
<div class="flex items-center gap-2">{{ actions }}</div>
{% endif %}
</div>
{{ slot }}
</div>
<c-vars :members="[]" :seats_used=0 :seats_total=0 :seats_left=0 :seats_pct=0 :seats_full="False" :plan="None" :signals="{}" :errors="{}" :roles="[]" />
<c-workspace.section
title="Team"
desc="Who can see your customers, invoices and forecasts.">
<div class="grid lg:grid-cols-2 gap-6">
<c-workspace.seats
:seats_used=seats_used
:seats_total=seats_total
:seats_left=seats_left
:seats_pct=seats_pct
:seats_full=seats_full
:plan=plan />
<c-lb.card class="border border-base-300">
<c-lb.card.body class="p-5 gap-4">
<div>
<h3 class="font-semibold">Invite a teammate</h3>
<p class="text-xs text-base-content/50 mt-1">
They get an email now and a seat when they accept.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<c-workspace.field
label="Work email"
required
:error=errors.email
validateTo="block_settings_workspace:invite_validate">
<c-lb.input
type="email"
class="w-full"
size="sm"
placeholder="[email protected]"
autocomplete="off"
:bind=signals.fields.email />
</c-workspace.field>
<c-workspace.field
label="Role"
on="change"
validateTo="block_settings_workspace:invite_validate">
<c-lb.select class="w-full" size="sm" :bind=signals.fields.role>
{% for value, label in roles %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-workspace.field>
</div>
<c-lbr.post to="block_settings_workspace:invite_save" tag="div" on="click">
<c-lb.button
type="button"
variant="primary"
btnStyle="soft"
size="sm"
modifier="block"
icon="rmx.user-add">
Send invite
</c-lb.button>
</c-lbr.post>
</c-lb.card.body>
</c-lb.card>
</div>
{% if members %}
<div class="overflow-x-auto border border-base-300 rounded-box">
<c-lb.table size="sm" class="w-full">
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Status</th>
<th>Last active</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<c-workspace.member-row :member=member />
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<div class="border border-dashed border-base-300 rounded-box p-10 text-center">
<p class="font-medium">Nobody here but you.</p>
<p class="text-sm text-base-content/60 mt-1">
Invite the people who chase renewals — they will see the same numbers you do.
</p>
</div>
{% endif %}
</c-workspace.section>
<c-vars message="" detail="" />
{% if message %}
<c-lb.toast vertical="bottom" horizontal="end" data-init="setTimeout(() => el.remove(), 5000)">
<c-lb.alert
variant="success"
alertStyle="soft"
icon="rmx.checkbox-circle"
class="border border-base-300 shadow-lg">
<div>
<div class="font-medium">{{ message }}</div>
{% if detail %}
<div class="text-xs opacity-70">{{ detail }}</div>
{% endif %}
</div>
</c-lb.alert>
</c-lb.toast>
{% endif %}
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Each settings area has a named target. A view can return the profile, team, or billing section without rebuilding the rest of the page.
-
The input writes to profile.slug and the preview reads that same value. The schema keeps the browser binding and the server-side field name aligned.
-
A debounced POST sends the field state to Django. The response returns the target section with the current validation result beside the field.
-
The save view validates, writes the workspace, then returns the profile target and a toast. Other settings sections keep their existing DOM and scroll position.
-
Changing a plan also changes the team allowance, so this response updates both targets. Use the same rule when one action affects more than one part of a page.