feat: Implement a web-based account management dashboard

This commit is contained in:
Ginger
2026-04-27 16:47:08 -04:00
parent 02948960fa
commit 6b0b8344d4
72 changed files with 2554 additions and 677 deletions
@@ -0,0 +1,19 @@
<div class="card">
{{ avatar() }}
<div class="info">
<p class="name">
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
&nbsp;<span class="id">{{ device_id }}</span>
</p>
<p>
Last active: {{ last_active }}
{% if let Some(metadata) = oauth_metadata %}
&nbsp;&bullet;&nbsp;<a href="{{ metadata.client_uri }}">Client information</a>
{% endif %}
</p>
</div>
</div>
@@ -1,30 +1,50 @@
{% macro errors(field_errors, name) %}
{% if let Some(errors) = field_errors.get(name) %}
{% for error in errors %}
<small class="error">
{% if let Some(message) = error.message %}
{{ message }}
{% else %}
Mysterious validation error <code>{{ error.code }}</code>!
{% endif %}
</small>
{% endfor %}
{% endif %}
{% endmacro %}
<form method="post">
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
{% let field_errors = validation_errors.field_errors() %}
{% for input in inputs %}
<p>
<label for="{{ input.id }}">{{ input.label }}</label>
{% let name = std::borrow::Cow::from(*input.id) %}
{% if let Some(errors) = field_errors.get(name) %}
{% for error in errors %}
<small class="error">
{% if let Some(message) = error.message %}
{{ message }}
{% else %}
Mysterious validation error <code>{{ error.code }}</code>!
{% endif %}
</small>
{% endfor %}
{% if input.input_type == "checkbox" %}
<label for="{{ input.id }}">
<input
type="checkbox"
id="{{ input.id }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{{ input.label }}
</label>
{{ errors(field_errors, name) }}
{% else %}
<label for="{{ input.id }}">{{ input.label }}</label>
{{ errors(field_errors, name) }}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
autocomplete="{{ input.autocomplete }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{% endif %}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
autocomplete="{{ input.autocomplete }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
</p>
{% endfor %}
<button type="submit">{{ submit_label }}</button>
<button type="submit"{% if slowdown %} class="slowdown"{% endif %}>{{ submit_label }}</button>
{% if slowdown %}
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
{% endif %}
</form>
@@ -1,9 +1,9 @@
<div class="user-card">
<div class="card green-avatar">
{{ avatar() }}
<div class="info">
{% if let Some(display_name) = display_name %}
<p class="display-name">{{ display_name }}</p>
<p class="name">{{ display_name }}</p>
{% endif %}
<p class="user_id">{{ user_id }}</p>
<p class="id">{{ user_id }}</p>
</div>
</div>
+5 -5
View File
@@ -9,17 +9,17 @@
<meta name="robots" content="noindex" />
{%- endif %}
<link rel="icon" href="/_continuwuity/resources/logo.svg">
<link rel="stylesheet" href="/_continuwuity/resources/common.css">
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
<link rel="icon" href="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/common.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/components.css">
{% block head %}{% endblock %}
</head>
<body>
<main>{%~ block content %}{% endblock ~%}</main>
{%~ block content %}{% endblock ~%}
{%~ block footer ~%}
<footer>
<img class="logo" src="/_continuwuity/resources/logo.svg">
<img class="logo" src="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
<p>Powered by <a href="https://continuwuity.org">Continuwuity</a> {{ env!("CARGO_PKG_VERSION") }}
{%~ if let Some(version_info) = conduwuit_build_metadata::version_tag() ~%}
{%~ if let Some(url) = conduwuit_build_metadata::GIT_REMOTE_COMMIT_URL.or(conduwuit_build_metadata::GIT_REMOTE_WEB_URL) ~%}
+53
View File
@@ -0,0 +1,53 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Your account
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Manage your account</h1>
{{ user_card }}
<section>
{% if email_requirement.may_change() %}
<p>
{% if let Some(email) = email %}
Your account's associated email address is <code>{{ email }}</code>.
{% else %}
Your account has no associated email address.
{% endif %}
<a href="email/change/">Change your email</a>
</p>
{% endif %}
<p>
<a href="password/change">Change your password</a>
</p>
</section>
<section>
<a class="button fullwidth" href="logout">Log out</a>
</section>
<section>
<details>
<summary>Your devices ({{ devices.len() }})</summary>
<div class="card-list">
{% for device in devices %}
{{ device }}
{% endfor %}
</div>
</details>
</section>
<section>
<details>
<summary>Danger zone</summary>
<p>
Settings here <em class="negative">may affect the integrity of your account</em>.
</p>
<a href="cross_signing_reset">Reset your digital identity</a> &bullet;
<a href="deactivate">Deactivate your account</a>
</details>
</section>
</div>
{%- endblock -%}
@@ -0,0 +1,33 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your email</h1>
{{ user_card }}
{% match body %}
{% when ChangeEmailBody::ValidationPending { session_id, client_secret, validation_error } %}
<p>
A message has been sent to your new email address with a validation link. If you do not receive the email:
<ul>
<li>Check your spam filter.</li>
</ul>
</p>
{% if validation_error %}
<small class="error">Validation failed. Have you clicked the link in the email that was sent to you?</small>
{% endif %}
<form method="get" action="validate">
<input type="hidden" name="session_id" value="{{ session_id }}">
<input type="hidden" name="client_secret" value="{{ client_secret }}">
<button type="submit">Continue</button>
</form>
{% when ChangeEmailBody::Success %}
<p>
Your email address has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,35 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Change your email <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ user_card }}
<p>
Your email address will be used for automated emails, such as password reset requests. It is also
visible to your homeserver's administrator, who may use it to contact you directly.
</p>
<p>
{% if let Some(email) = email %}
Your account's associated email address is <code>{{ email }}</code>.
To change your email address, enter your new address below.
{% else %}
Your account has no associated email address. To add an email address, enter it below.
{% endif %}
</p>
{{ form }}
{% if may_remove %}
<p>
You may remove your email address. Note that, if your account has no email address,
you will not be able to reset your password if you forget it.
</p>
<form method="post" action="delete">
<button type="submit">Remove your email address</button>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Change your password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your password</h1>
{{ user_card }}
{% match body %}
{% when ChangePasswordBody::Form(reset_form) %}
{{ reset_form }}
<a class="reset-password" href="reset/"><i>Forgot your password?</i></a>
{% when ChangePasswordBody::Success %}
<p>
Your password has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,43 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your digital identity
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Reset your digital identity <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ user_card }}
{% match body %}
{% when CrossSigningResetBody::Form %}
<p>
If you've lost your end-to-end encryption recovery key, you need to reset your digital identity to continue
using end-to-end encryption.
</p>
<p>
<b>You don't need to do this</b> if you still have access to a confirmed device. You can use that device
to change your recovery key without resetting your digital identity. Only reset your digital identity if you are
absolutely sure that you have lost your recovery key and can't use any of your confirmed devices.
</p>
<p>
What will happen:
<ul>
<li>✅ Your account information, joined chatrooms, and preferences will not change.</li>
<li>⚠️ You will <em class="negative">permanently lose access</em> to your encrypted message history.</li>
<li>⚠️ You will need to confirm your devices and verify your contacts again.</li>
</ul>
</p>
<form method="post">
<button type="submit" class="slowdown">I understand, begin the reset process</button>
</form>
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
{% when CrossSigningResetBody::Success %}
<p>
The identity reset has been approved for the next ten minutes.
Return to your Matrix client to finish resetting your identity.
Remember that you will <em class="negative">permanently lose access</em>
to your encrypted message history if you continue.
</p>
{% endmatch %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Deactivate your account
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Deactivate your account <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{% match body %}
{% when DeactivateBody::Form { user_id, user_card, form } %}
{{ user_card }}
<p>
<em class="negative">Please read this carefully. Deactivating your account is a permanent action.</em>
</p>
<p>
What will happen:
<ul>
<li>Your account will be <em class="negative">permanently locked.</em>
You will not be able to reactivate it or sign back in.</em>
<li>Nobody, including you, will <b>ever</b> be able to re-use the user ID <code>{{ user_id }}</code>.</li>
<li>Your profile information will be wiped from the server.</li>
<li>You will be removed from all chatrooms and direct messages you are in.</li>
</ul>
</p>
<p>
Your messages will remain in chatrooms you were participating in.
</p>
<hr>
{{ form }}
{% when DeactivateBody::Success %}
<p>
Your account has been deactivated and you have been signed out of Matrix.
</p>
{% endmatch %}
</div>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your email</h1>
{{ user_card }}
<p>
Your email address has been removed. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
</div>
{% endblock %}
+28 -26
View File
@@ -1,7 +1,7 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/error.css">
{%- endblock -%}
{%- block title -%}
@@ -9,33 +9,35 @@
{%- endblock -%}
{%- block content -%}
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /   
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
Internal server error
{% else %}
Bad request
<div class="error-body">
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /  ヽ  
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel middle">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
Internal server error
{% else %}
Bad request
{% endif %}
</h1>
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
</h1>
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
<pre><code>{{ error }}</code></pre>
<pre style="white-space: pre-wrap"><code>{{ error }}</code></pre>
</div>
</div>
{%- endblock -%}
+3 -2
View File
@@ -1,11 +1,11 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/index.css">
{%- endblock -%}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<h1>
Welcome to <a class="project-name" href="https://continuwuity.org">Continuwuity</a>!
</h1>
@@ -15,6 +15,7 @@
<p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p>
{%- else %}
<p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ server_name }}</code>.</p>
<p><a href="{{ crate::ROUTE_PREFIX }}/account/">Manage your account</a></p>
{%- endif %}
</div>
+53
View File
@@ -0,0 +1,53 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Log in
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
{% match body %}
{% when LoginBody::Unauthenticated { server_name } %}
<h1 class="with-matrix-icon">
Log in to Matrix
<a href="https://matrix.org" target="_blank" noreferer>
<img class="matrix-icon" alt="Matrix logo" aria-ignore src="{{ crate::ROUTE_PREFIX }}/resources/matrix-icon.svg">
</a>
</h1>
<p>
You're about to log in to your account on <em>{{ server_name }}</em>
</p>
<hr>
<form method="post">
<p>
<label for="identifier">Username or email address</label>
<input type="text" name="identifier" autocomplete="username">
</p>
<p>
<label for="password">Password</label>
<input type="password" name="password" autocomplete="current-password">
</p>
<button type="submit">Log in</button>
</form>
{% when LoginBody::Authenticated { user_card } %}
<h1>Confirm your identity</h1>
{{ user_card }}
<p>Enter your password to continue.</p>
<form method="post">
<p>
<label for="password">Password</label>
<input type="password" name="password" autocomplete="current-password">
</p>
<button type="submit">Continue</button>
</form>
{% endmatch %}
{% if let Some(error) = login_error %}
<small class="error">{{ error }}</small>
{% endif %}
<a class="reset-password" href="password/reset/"><i>Forgot your password?</i></a>
</div>
{%- endblock -%}
@@ -1,18 +0,0 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset Password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Reset Password</h1>
{{ user_card }}
{% match body %}
{% when PasswordResetBody::Form(reset_form) %}
{{ reset_form }}
{% when PasswordResetBody::Success %}
<p>Your password has been reset successfully.</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,36 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Reset your password</h1>
{% match body %}
{% when ResetPasswordBody::ValidationPending { session_id, client_secret, validation_error } %}
<p>
Check your inbox for the validation email. If you do not receive the email:
<ul>
<li>Check your spam filter.</li>
<li>Your Matrix account may not be associated with an email address. Contact your homeserver's
administrator for assistance.</li>
</ul>
</p>
{% if validation_error %}
<small class="error">Validation failed. Have you clicked the link in the email that was sent to you?</small>
{% endif %}
<form method="get" action="validate">
<input type="hidden" name="session_id" value="{{ session_id }}">
<input type="hidden" name="client_secret" value="{{ client_secret }}">
<button type="submit">Continue</button>
</form>
{% when ResetPasswordBody::ValidationSuccess { user_card, form } %}
{{ user_card }}
{{ form }}
{% when ResetPasswordBody::ResetSuccess { user_card } %}
{{ user_card }}
<p>Your password has been reset successfully.</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,33 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your password
{%- endblock -%}
{%- block content -%}
{% decl body_class -%}
{% if let ResetPasswordRequestBody::Unavailable = body -%}
{% let body_class = "panel middle" -%}
{% else -%}
{% let body_class = "panel" -%}
{% endif -%}
<div class="{{ body_class }}">
<h1>Reset your password</h1>
{% match body %}
{% when ResetPasswordRequestBody::Form(form) %}
<p>
To reset your password, enter your email below. If your Matrix account has an associated email address,
you will receive an email with a link to reset your password.
</p>
<p>
If your Matrix account does not have an associated email address, contact your homeserver's administrator
to reset your password.
</p>
{{ form }}
{% when ResetPasswordRequestBody::Unavailable %}
<p>
To reset your password, contact your homeserver's administrator.
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -1,8 +1,12 @@
{% extends "_layout.html.j2" %}
{% block title %}
Email verification
{% endblock %}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<h1>Email verification</h1>
<p>Your email address has been verified. Return to your Matrix client to continue.</p>
<p>Your email address has been verified. Please continue in the original application.</p>
</div>
{%- endblock content -%}