2026-05-20 23:55:27 +02:00
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml , attr , escape , trusted
from .templates import (
_copy_button ,
_copy_row ,
_fritzbox_fields ,
_manual_update_commands ,
_manual_update_panel ,
_one_time_secret_notice ,
_page ,
_secret_notice ,
_top_nav ,
)
from .templates_public_sections import _credentials_panel
def _render_management_page (
service : DdnsService ,
settings : DdnsSettings ,
management_slug : str ,
account : dict [ str , str | int | None ],
csrf_token : str ,
message : str | None = None ,
challenge : dict [ str , str | None ] | None = None ,
created_account : dict [ str , str ] | None = None ,
) -> str :
account_for_url = {
"hostname" : str ( account [ "hostname" ]),
"username" : str ( account [ "username" ]),
"password" : "" ,
"update_slug" : str ( account [ "update_slug" ]),
"management_slug" : management_slug ,
}
update_url = service . fritz_update_url ( account_for_url )
hostname = escape ( account [ "hostname" ])
username = escape ( account [ "username" ])
ipv4 = escape ( account . get ( "ipv4" ) or "-" )
ipv6 = escape ( account . get ( "ipv6" ) or "-" )
updated = escape ( account . get ( "updated_at" ) or "Never" )
fritzbox_fields = _fritzbox_fields ( update_url , hostname , username , "unchanged; only shown when generated" )
cname_record = f "your-subdomain.example.com. CNAME { hostname } ."
manual_commands = _manual_update_commands ( update_url )
history_rows = " \n " . join ( _user_event_row ( event ) for event in service . store . list_update_events_for_hostname ( str ( account [ "hostname" ]), 10 )) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
message_html = _management_message ( message )
rotated_credentials = _credentials_panel ( service , created_account )
domain_claim = _management_domain_claim_panel ( service , management_slug , csrf_token , challenge )
dashboard_password_panel = _dashboard_password_panel ( management_slug , csrf_token , bool ( account . get ( "dashboard_password_hash" )))
return _page (
"KarlDNS management" ,
trusted ( f """
<main id="main">
{ _top_nav () }
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
2026-05-21 10:05:40 +02:00
<p class="eyebrow">Dashboard</p>
2026-05-20 23:55:27 +02:00
<h1> { hostname } </h1>
2026-05-21 10:05:40 +02:00
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
2026-05-20 23:55:27 +02:00
</div>
</section>
{ message_html }
{ rotated_credentials }
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. For your own subdomain, create a CNAME to this hostname.</p>
2026-05-20 23:55:27 +02:00
{ _one_time_secret_notice () }
{ _secret_notice () }
<div class="d-flex gap-2 flex-wrap mt-3">
2026-05-21 10:05:40 +02:00
{ _copy_button ( "Copy router fields" , fritzbox_fields ) }
{ _copy_button ( "Copy CNAME" , cname_record ) }
2026-05-20 23:55:27 +02:00
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
{ _copy_row ( "Update-URL:" , update_url , "Secret" ) }
{ _copy_row ( "Domainnamen:" , hostname , "Public" ) }
{ _copy_row ( "CNAME target:" , hostname , "Public" ) }
{ _copy_row ( "Benutzername (optional):" , username , "Public" ) }
{ _copy_row ( "Kennwort (optional):" , "unchanged; only shown when generated" , "Secret" ) }
</div>
</div>
{ _manual_update_panel ( manual_commands ) }
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
2026-05-21 10:05:40 +02:00
<h3>Replace router secrets</h3>
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
2026-05-20 23:55:27 +02:00
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/ { attr ( management_slug ) } /rotate">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<input type="hidden" name="action" value="update">
2026-05-21 10:05:40 +02:00
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
2026-05-20 23:55:27 +02:00
</form>
<form method="post" action="/m/ { attr ( management_slug ) } /rotate">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<input type="hidden" name="action" value="password">
2026-05-21 10:05:40 +02:00
<button class="btn btn-outline-primary rounded-pill" type="submit">New password</button>
2026-05-20 23:55:27 +02:00
</form>
</div>
</div>
</div>
</section>
{ domain_claim }
{ dashboard_password_panel }
<section class="section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Current address</p>
<h2>Status</h2>
</div>
<div class="table-responsive status-table-shell">
<table class="table status-table mb-0">
<tbody>
<tr><th>IPv4</th><td> { ipv4 } </td></tr>
<tr><th>IPv6</th><td> { ipv6 } </td></tr>
<tr><th>Updated</th><td> { updated } </td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
2026-05-20 23:55:27 +02:00
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody> { history_rows } </tbody>
</table>
</div>
</div>
</section>
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
2026-05-21 10:05:40 +02:00
<p class="eyebrow">Delete</p>
2026-05-20 23:55:27 +02:00
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
<form method="post" action="/m/ { attr ( management_slug ) } /delete">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</section>
</main>
""" ),
2026-05-21 10:05:40 +02:00
extra_styles = trusted ( '<link href="/admin.css?v=2" rel="stylesheet">' ),
2026-05-20 23:55:27 +02:00
)
def _management_message ( message : str | None ) -> SafeHtml :
if not message :
return trusted ( "" )
return trusted (
f """
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container"> { escape ( message ) } </div>
</div>
"""
)
def _dashboard_password_panel ( management_slug : str , csrf_token : str , password_enabled : bool ) -> SafeHtml :
password_status = "Enabled" if password_enabled else "Not enabled"
2026-05-21 10:05:40 +02:00
password_action = "Change password" if password_enabled else "Set password"
2026-05-20 23:55:27 +02:00
remove_form = ""
if password_enabled :
remove_form = f """
<form class="card card-body gap-3" method="post" action="/m/ { attr ( management_slug ) } /password/remove">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
2026-05-21 10:05:40 +02:00
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
2026-05-20 23:55:27 +02:00
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
2026-05-21 10:05:40 +02:00
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
2026-05-20 23:55:27 +02:00
</form>
"""
current_password_field = ""
if password_enabled :
current_password_field = """
<div>
2026-05-21 10:05:40 +02:00
<label class="form-label" for="dashboard-current-password">Current password</label>
2026-05-20 23:55:27 +02:00
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted (
f """
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
2026-05-21 10:05:40 +02:00
<p class="section-copy">Status: { escape ( password_status ) } . This password protects only this dashboard. Router updates still work.</p>
2026-05-20 23:55:27 +02:00
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/ { attr ( management_slug ) } /password">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
{ current_password_field }
<div>
<label class="form-label" for="dashboard-password"> { escape ( password_action ) } </label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit"> { escape ( password_action ) } </button>
</div>
</form>
{ remove_form }
</div>
</div>
</div>
</section>
"""
)
def _render_management_unlock_page (
management_slug : str ,
account : dict [ str , str | int | None ],
csrf_token : str ,
message : str | None = None ,
) -> str :
hostname = escape ( account [ "hostname" ])
message_html = ""
if message :
message_html = f '<div class="alert alert-warning" role="alert"> { escape ( message ) } </div>'
return _page (
"Unlock KarlDNS management" ,
trusted ( f """
<main id="main">
{ _top_nav () }
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1> { hostname } </h1>
2026-05-21 10:05:40 +02:00
<p class="lead">Enter the dashboard password for this hostname.</p>
2026-05-20 23:55:27 +02:00
</div>
</section>
<section class="section">
<div class="container narrow">
{ message_html }
<form class="card card-body gap-3" method="post" action="/m/ { attr ( management_slug ) } /unlock">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
2026-05-21 10:05:40 +02:00
<span class="form-check-label">Remember this browser for 30 days</span>
2026-05-20 23:55:27 +02:00
</label>
2026-05-21 10:05:40 +02:00
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
2026-05-20 23:55:27 +02:00
</form>
</div>
</section>
</main>
""" ),
)
def _management_domain_claim_panel (
service : DdnsService ,
management_slug : str ,
csrf_token : str ,
challenge : dict [ str , str | None ] | None ,
) -> SafeHtml :
create_claim = trusted (
f """
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
2026-05-21 10:05:40 +02:00
<h2>Use your domain</h2>
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
2026-05-20 23:55:27 +02:00
</div>
<form method="post" action="/m/ { attr ( management_slug ) } /domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
2026-05-21 10:05:40 +02:00
<button type="submit" class="btn btn-primary rounded-pill">Create claim</button>
2026-05-20 23:55:27 +02:00
</form>
</div>
"""
)
if not challenge :
return trusted (
f """
<section class="section">
<div class="container narrow"> { create_claim } </div>
</section>
"""
)
domain = str ( challenge [ "domain" ])
claim_secret = str ( challenge . get ( "claim_secret" ) or "" )
verification_name = service . verification_name ( domain )
token = str ( challenge [ "token" ])
claim_url = service . domain_claim_url ( challenge )
2026-05-21 10:05:40 +02:00
status = "Verified" if challenge . get ( "verified_at" ) else "Waiting for TXT"
2026-05-20 23:55:27 +02:00
txt_bundle = f "TXT name: { verification_name } \n TXT value: { token } "
create_hostname = _management_custom_hostname_form ( management_slug , claim_secret , csrf_token ) if challenge . get ( "verified_at" ) else trusted ( "" )
return trusted (
f """
<section class="section" id="custom-domain">
<div class="container narrow d-grid gap-4">
{ create_claim }
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Domain claim</p>
<h2> { escape ( domain ) } </h2>
<p class="section-copy">Status: { escape ( status ) } </p>
2026-05-21 10:05:40 +02:00
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Other dashboards cannot use it.</p>
2026-05-20 23:55:27 +02:00
</div>
2026-05-21 10:05:40 +02:00
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. Claim secret and private claim link are secret.</div>
2026-05-20 23:55:27 +02:00
<div class="d-flex gap-2 flex-wrap">
2026-05-21 10:05:40 +02:00
{ _copy_button ( "Copy TXT" , txt_bundle ) }
2026-05-20 23:55:27 +02:00
</div>
<div class="d-grid gap-2">
{ _copy_row ( "TXT name" , verification_name , "Public" ) }
{ _copy_row ( "TXT value" , token , "Public" ) }
{ _copy_row ( "Claim secret" , claim_secret , "Secret" ) }
{ _copy_row ( "Private claim link" , claim_url , "Secret" ) }
</div>
<form method="post" action="/m/ { attr ( management_slug ) } /domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">
<input type="hidden" name="domain" value=" { attr ( domain ) } ">
<input type="hidden" name="claim_secret" value=" { attr ( claim_secret ) } ">
2026-05-21 10:05:40 +02:00
<button type="submit" class="btn btn-primary rounded-pill">Verify</button>
2026-05-20 23:55:27 +02:00
</form>
{ create_hostname }
</div>
</div>
</section>
"""
)
def _management_custom_hostname_form ( management_slug : str , claim_secret : str , csrf_token : str | None = None ) -> SafeHtml :
csrf_field = f '<input type="hidden" name="csrf" value=" { attr ( csrf_token ) } ">' if csrf_token else ""
return trusted (
f """
<form method="post" action="/m/ { attr ( management_slug ) } /domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{ csrf_field }
<input type="hidden" name="claim_secret" value=" { attr ( claim_secret ) } ">
2026-05-21 10:05:40 +02:00
<label>Hostname
2026-05-20 23:55:27 +02:00
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
2026-05-21 10:05:40 +02:00
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
2026-05-20 23:55:27 +02:00
</form>
"""
)
def _user_event_row ( event : dict [ str , str | int | None ]) -> str :
created = escape ( event . get ( "created_at" ) or "-" )
status = escape ( event . get ( "status" ) or "-" )
ipv4 = escape ( event . get ( "ipv4" ) or "-" )
ipv6 = escape ( event . get ( "ipv6" ) or "-" )
count = escape ( event . get ( "count" ) or 1 )
detail = escape ( event . get ( "detail" ) or "-" )
return f "<tr><td> { created } </td><td> { status } </td><td> { ipv4 } </td><td> { ipv6 } </td><td> { count } </td><td> { detail } </td></tr>"