DAV was internal-only (the node's :443 is Traefik's). New mail-dav
Ingress routes /.well-known/caldav, /.well-known/carddav and /dav on
mail.dezky.eu through to Stalwart — with the HTTPS-redirect middleware
(safe for DAV's GET/PROPFIND; kept OFF the autodiscover Ingress whose
POSTs don't survive redirects). The _caldavs/_carddavs SRV records are
now legitimate, so the Domains page surfaces them, and the Apple
.mobileconfig gains CalDAV + CardDAV payloads: one install sets up Mail,
Calendar and Contacts on Mac/iPhone. Stalwart's STALWART_PUBLIC_URL is
set to https://mail.dezky.eu on the host (discovery documents).
The .mobileconfig button only lived on the transient credential dialogs
(invite/create/reset results) — nowhere to fetch a profile for an
existing mailbox. Row kebab menu gains 'Download Apple Mail profile'
(mailbox users only) and the user drawer shows the button next to the
mailbox address.
Apple Mail ignores RFC 6186 SRV autodiscovery and 'Microsoft Exchange'
needs EWS/EAS that Stalwart doesn't speak — so custom-domain users were
stuck typing IMAP/SMTP servers manually. New session-gated portal route
generates an Apple configuration profile (IMAP 993 + SMTP 465 on the
runtime mail host, username = address, NO password embedded — profiles
are plaintext, Apple prompts at install). 'Add to Apple Mail' buttons on
the three credential screens (invite result, mailbox created, password
reset). CalDAV/CardDAV payloads join when DAV is reachable from outside
(the node's :443 belongs to Traefik for now).
DomainView.checks was a hardcoded five-kind union, so indexing it with the
new autodiscovery RecordKey failed the portal typecheck (CI red on
f6bac10). Use Record<RecordKind, RecordStatus>.
Mail clients could never autoconfigure: Stalwart's zone file contains the
_imaps/_submissions/_pop3s SRV records but classify() dropped everything
except mx/spf/dkim/dmarc, so customers never saw them and every client
needed manual server entry. New 'autodiscovery' record kind: classified
from the zone (only the services actually reachable in prod — the
_jmap/_caldavs SRVs target :443 which Traefik owns, deferred to the
webmail story), verified via resolveSrv (missing=bad, wrong target=warn),
shown as an OPTIONAL slot on the portal Domains page that never gates the
domain status or the records-to-fix nag.
Also fixed on the live server via management JMAP (x:SystemSettings):
hostname was the machine name node1.dezky.eu from the v0.16 auto-bootstrap
— MX/SRV targets and the SMTP banner now say mail.dezky.eu, and the LE
x:Certificate is set as defaultCertificateId.
Identifying the company tenant by slug in env was fragile — every
purge/recreate changed the slug (or id) and the apex guard chased reality
through three config flips in one day. The identity now lives ON the
tenant document: isPlatformTenant, operator-set from the tenant page
(single holder — setting it clears the flag everywhere else), guarded so
tenant admins can't set it on themselves through the shared PATCH route.
The dezky.eu apex guard reads the flag; PLATFORM_TENANT_SLUG is gone.
Dev seed flags its seeded tenant. config-rev 5 rolls platform-api.
The minimal create modal silently dropped adminName/adminEmail — the invite
only existed in the partner wizard's server path. Operator now gets the
same 5-step wizard UX (organization, domain, first admin, plan with live
price catalog, review) composed client-side: POST /tenants creates +
provisions, then POST /users/invite-tenant-admin (new, operator-only —
lives in UsersModule because UsersModule already imports TenantsModule and
the reverse would be circular) runs the same inviteTenantAdmin flow the
partner gets, and the result view hands over the single-use recovery link
or temp password. Tenant detail page gains an Invite admin action for
retries/successors. PLATFORM_TENANT_SLUG back to 'dezky' (the recreated
company tenant) + config-rev bump to roll platform-api.
Soft-delete kept the slug occupied forever — no way to remove a test tenant
and reuse its name, and external resources lingered. DELETE
/tenants/:slug/purge (platform-admin only, two-step: refuses anything not
already soft-deleted) tears down the Stalwart service + customer domains
(never the platform apex — the management admin account lives there) and
the Authentik group, then removes domains/subscriptions/invoices/user
links/the tenant doc. Audit trail is kept. Operator detail page shows a
'Purge permanently' card once a tenant is soft-deleted.
Operator sign-out hardcoded the dev Authentik end-session URL, so prod
logout landed on auth.dezky.local. Mirror the portal's env-driven pattern
(NUXT_PUBLIC_AUTH_URL/NUXT_PUBLIC_OPERATOR_URL with .local fallbacks).
Expose authUrl/operatorUrl via public runtimeConfig and use them for the
Authentik admin links and the cosmetic host labels (sidebar, eyebrows,
auth-page hints). Portal: signed-out + webmail copy now derive their hosts
from runtime config (new public.mailUrl, NUXT_PUBLIC_MAIL_URL in prod).
nuxt-oidc-auth registers its own 'oidc' storage mount at build, so
storage.mount('oidc', …) at runtime threw 'already mounted at oidc:' and
crash-looped the new pods. Unmount the memory mount first.
nuxt-oidc-auth persists sessions via useStorage('oidc'), whose default
mount is per-pod memory — broken at >1 replica (random 401s) and every
deploy logged all users out. A nitro plugin now mounts 'oidc' on the
dezky-data Redis (db 1, app-prefixed keys, 14d TTL) when SESSION_REDIS_URL
is set; dev keeps the memory driver with no Redis required. Replicas back
to 2 for both apps.
The operator could list and inspect tenants but had no create flow — tenant
creation only existed as the partner-portal wizard, which always attaches a
partnerId. Platform-api's POST /tenants (platform-admin only, no partner
field) was already built for this; add the missing UI: a New tenant modal on
the tenants page (slug, name, plan/cycle/currency/seats, optional primary
mail domain + first-admin invite) and the server proxy route. Operator-created
tenants are direct customers; attach a partner later if needed.
- Dockerfile for the operator app (same pattern as portal/booking).
- Env-driven auth/app base URLs in nuxt.config so one build serves
dev (.local) and production (.eu).
- Deployment + Service + Ingress on operator.dezky.eu.
- Add operator to the typecheck matrix.
timeToMin destructured [h, m] from t.split(':').map(Number); under
noUncheckedIndexedAccess those are number|undefined, so `h * 60` errored. Use
default-value destructuring ([h = 0, m = 0]). Surfaced now that the Gitea runner
actually runs the typecheck job (it never ran before).
The apps were wired for the dev (.local) environment. Drive the base URLs from
env so one build serves dev and prod (.eu):
- portal nuxt.config: OIDC authorization/token/userinfo/discovery URLs +
redirectUri now derive from NUXT_PUBLIC_AUTH_URL / NUXT_PUBLIC_PORTAL_URL
(+ PORTAL_OIDC_APP_SLUG); .local defaults keep dev working with no env.
- portal sign-out handler: end-session + post-logout URLs env-driven.
- portal scheduling page: booking base/host from runtimeConfig.public.bookingUrl
(NUXT_PUBLIC_BOOKING_URL).
- platform-api: tenant mail domain suffix from PLATFORM_TENANT_DOMAIN (dezky.eu
in prod), defaulting to dezky.local.
(booking needs no change — its only .local ref is the dev-server allowedHosts.)
The "Jump to" launcher only navigated for the internal tiles (Personal /
Admin / Partner); every external app (Mail, Drev, Møder, …) just fired a
toast and never opened. Hosts were also hardcoded to *.dezky.com, with Drev
pointing at a vanity drev. subdomain instead of the real OCIS host.
- Open external apps in a new tab at https://<host>.<baseDomain>
- Derive the base domain from the portal's own hostname so links resolve in
every environment (app.dezky.local → dezky.local, app.dezky.com → dezky.com)
- Map Drev → files (OCIS); mail/meet/chat/cal/contacts/docs use their service
subdomain
Rebuild the /admin/users detail drawer from a read-only profile into an
editable, Office 365-style panel with four sections:
- Username & mail: read-only primary for mailbox users; editable sign-in
(Authentik-only) for mailbox-less identities; "Create mailbox" provisions
a Stalwart inbox for an external-login admin
- Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped
- Role: member/admin toggle with a primary-account lock (owner, mailbox-less
bootstrap admin, self) and a last-admin guard
- Contact information: display name, first/last name, phone, alternative
email — mirrored best-effort to Authentik attributes + mailbox name
Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side
"Transfer ownership" picker, gated to tenant admins / platform admins so a
departed owner can be replaced; promotes the target and demotes the prior
owner to admin.
Backend (platform-api): contact fields on User; AuthentikClient.updateUser;
StalwartClient.setMailboxName; UsersService updateTenantMember,
changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember,
transferOwnership; new DTOs and tenant-member routes. All mutations audited.
Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
Surface pending/calendar_failed booking states in the admin bookings list with
proper status badges (failed shows the last calendar error as a tooltip), and
add an operator "Retry now" action. The retry re-drives the same Stalwart
calendar write (confirm + attendee email on success); for a terminal
calendar_failed booking it re-claims the slot lock atomically first and refuses
if the time was taken in the meantime, so a manual retry can never double-book.
Customer-admin Mail settings backed by Stalwart JMAP: per-tenant aliases
(extra addresses routing to a mailbox) and distribution lists (one address
fanning out to many recipients). Adds StalwartClient x:Alias/x:MailingList
methods, a tenant-scoped MailController/MailService, the portal Mail settings
page and its proxy routes, and the mailboxAddress field on TenantUserDoc.
Removes the old mock mail data now that the page reads live data.
Add a standalone bilingual /coming-soon page (branded, dark, email signup
via mailto:info@dezky.eu, fires a waitlist-signup Umami event, noindex) plus
a global middleware that redirects every route to the locale-correct holding
page while NUXT_PUBLIC_COMING_SOON=true.
- Env-gated (default off, so dev and the live site are unaffected); flip the
env in Coolify to show/hide the holding page with no code change.
- Preview the real site behind the gate via ?preview=<previewToken>
(NUXT_PUBLIC_PREVIEW_TOKEN), which sets a 7-day cookie.
- Locale-preserving redirects (/da/* -> /da/coming-soon), no loops.
Make the marketing site deployable standalone from apps/website (the repo
has no root package.json, so the build must run in this subdir — set Base
Directory to apps/website in Coolify).
- Add a multi-stage Dockerfile (pnpm build -> node .output/server/index.mjs,
port 3000, NUXT_PUBLIC_SITE_URL default) + .dockerignore.
- Add a "start" script for the Nixpacks path (Nuxt SSR has no default start).
- Guard the bind-mount-only /shared-packages components dir with existsSync
so standalone builds don't warn on the missing path (the site uses no
shared component).
Add the Umami tracker (cookieless, no consent banner) in the document head,
limited to the production hostnames via data-domains so dev traffic doesn't
pollute the stats. Pageviews are auto-tracked per page and locale.
Custom events on the key funnel:
- demo-request (demo form submit, with teamSize)
- partner-application (partner form submit, with type)
- book-demo (every "Book a demo" CTA click) via data-umami-event
- login (clicks through to the app)
Also fix the mobile nav menu links, which weren't localized (would drop
Danish visitors back to English).
Add @nuxtjs/i18n: English is the default locale (no prefix), Danish lives
under /da (prefix_except_default). Both server-rendered and indexed with
hreflang alternates + per-locale canonical (useLocaleHead in app.vue).
First-visit browser language is auto-detected and remembered in the
i18n_redirected cookie (redirectOn root).
- Keep the hand-authored COPY object; useLang/useCopy now read the i18n
locale; useLocalizeHref/useLangToggle added. Every internal link is
localized so navigation stays in-locale.
- Clear segmented EN|DA language switcher (active segment filled) replacing
the ambiguous "en · da" pill.
- SEO: useSeoMeta defaults are locale-aware; og:image switches per locale
(English / Danish share card); favicon links; robots.txt + sitemap.xml;
env-aware siteUrl/baseUrl (localhost in dev, dezky.eu in prod).
- Update the cookie policy (dezky-lang -> i18n_redirected).
- Gate the Traefik wss:443 HMR behind DEZKY_TRAEFIK so localhost dev/DevTools
connect over plain ws (fixes the DevTools disconnect loop).
Replace the bare partner CTA with a "Become a partner" section: a pitch
column (with a secondary "book a partner call" link to /demo) plus an
application form — name, company, work email, website, partnership type
(reseller / white-label / not sure) and message. On submit it composes a
prefilled email to info@dezky.eu (interim, no backend), to be swapped for
a real intake/CRM later. Bilingual and responsive.
- Write the brand name as lowercase "dezky" across all user-facing copy
(the legal entity "Dezky ApS" stays capitalised).
- Change the general contact email kontakt@dezky.eu -> info@dezky.eu.
- Footer tagline now "European" rather than only Danish business.
- Compare table header logo uses the on-dark treatment (signal-green
squircle + carbon d) instead of a low-contrast green d on a light chip.
- /demo: book-a-demo page with a what-to-expect column + a form that
composes a prefilled email to info@dezky.eu (interim, no backend); built
to swap for a self-hosted scheduler later. Wire every "Book a demo" CTA
(nav, hero, pricing, the previously-dead final-CTA button, and the
contact/partners/migration/coming-soon CTAs) to /demo.
- /status: manually-maintained system-status page (overall banner,
per-service rows, incident history). Live modules operational; Video/Chat
marked coming soon.
- Roadmap: expand the board (5 items/column) + a "the bigger picture"
themes grid + a "suggest a feature" CTA + a directional-timelines note.
- Contact: purpose-specific channels (info@ / legal@ / privacy@), a
response-time note, and a company + "see it live" demo block.
- Drop /status from the [slug].vue stub map; tidy now-unused imports.
Extend /about with the "why": a "the problem" section (jurisdiction vs
geography, creeping prices, lock-in, vendor sprawl) as a 2x2 pain grid, and
a "we're founders ourselves" section. Both bilingual and responsive.
Also point the footer partner link to /partners (was the homepage
#whitelabel section).
Replace the /sla and /cookies stubs with real pages (da + en), matching
the DPA/terms/privacy layout. Drop both from the [slug].vue stub map.
- SLA: 99% uptime target framed as a transparency-backed commitment (no
public service credits); contractual credits offered only via Enterprise
agreements. Support first-response targets table; status-page reference.
- Cookie policy: strictly-necessary + language preference only, cookie-free
analytics (Umami), no third-party tracking, "no banner needed"; cookie
table; final (v1.0), no draft banner.
useLang now seeds from a first-party `dezky-lang` cookie on SSR init and
toggleLang writes it (12 months, SameSite=Lax). The chosen language now
survives reloads/return visits (fixes the reset-to-Danish quirk) and makes
the cookie-policy entry accurate.
Replace the /terms stub with a real Servicevilkår page (da + en),
mirroring the DPA/privacy layout and draft / legal-review banner.
- 12 clauses tailored to the system: the service, per-user accounts,
acceptable use, customer data (EU-hosted, DPA ref, export/no lock-in),
pricing & payment (49 kr/user/mo excl. VAT), term/renewal/cancellation,
uptime & SLA ref, IP, liability cap (last 12 months' fees), changes,
Danish law / venue Esbjerg
- Drop 'terms' from the [slug].vue stub map so the real page wins
- Contact: legal@dezky.eu
Still a 0.1 draft pending legal review (flagged on the page).
Make the marketing site mobile-friendly across every page and section.
Desktop appearance is unchanged; all breakpoint logic targets <=768px.
- Fluid section padding via clamp(); equal grids use auto-fit/minmax,
asymmetric grids stack to one column via scoped-CSS media queries
- Nav: real hamburger menu on mobile (links, lang toggle, login, CTA)
- ProductMockup: scales the whole dashboard to fit (zoom) instead of
reflowing its internals into a tall stack
- Lower oversized heading clamp() minimums so titles no longer overflow
at ~390px (hero, page headers, final CTA, brand cover/chapter)
- HowItWorks: row-gap when steps stack so node markers clear the text
- Compare + partners tables: stacked rows now label each value with its
column (Dezky vs hyperscaler / CSP) instead of an ambiguous header
- Footer columns, tiers, calculator and tables stack cleanly on mobile
Replace the /brand stub with the full Brand Guide from the Claude Design
handoff (cover, logo, color, typography, voice, applications), rendered inside
the site layout, English-only, with illustrative copy adapted to Dezky's real
EU-sovereign voice. Extend NodeMark with a `variant` (donut/solid/outline) and
NodeLockup with a separate `wordmark` colour so the mark reads correctly on
dark surfaces. Drops the old brand stub copy.
Set TMPDIR=/tmp in the dev script so the Nuxt vite-node Unix socket path stays
under macOS's 104-char limit (fixes "Failed to restrict vite-node socket
permissions" on local runs; harmless in the Linux container). Also fix the
package.json description: dezky.com → dezky.eu.
The /partners margin calculator now derives margin from the user count using
progressive brackets (like tax brackets): first 500 users at 15%, 501–1000 at
30%, 1001+ at 40%, off the 49 kr/user/mo list price. Replaces the manual margin
slider with a live per-bracket breakdown. Rates are read from the tier copy;
tier thresholds aligned to 501 / 1.001 so the cards and calculator agree.
Add the /partners page rendering the partner pitch: benefits, an interactive
margin calculator (seats × margin → monthly/annual, off the 49 kr list price),
a reseller-facing "CSP vs Dezky" comparison, partner tiers, a 3-step "get
started", and a partner FAQ. Wire the section-06 "see the partner program"
button to it, and align the whitelabel margin bullet to 15–40%.
Wire every footer link to a real route. Adds a shared `page` layout (Nav +
content + Footer), reusable PageHeader/ComingSoon components, six content pages
(about, contact, brand, roadmap, changelog, migration), and a dynamic [slug]
catch-all for the not-yet-built pages — unknown slugs 404, legal slugs get a
distinct "contact us" body.
Footer links repointed from dead "#" to real paths; section anchors ("/#suite")
smooth-scroll on the homepage and route home + scroll from a sub-page; logo
links home. Page copy (da + en) added under COPY.pages.