add "not activated" warning indicators

This commit is contained in:
ari melody 2026-02-25 08:17:03 +00:00
parent c22ab15895
commit 92edec869f
Signed by: ari
GPG key ID: CF99829C92678188
7 changed files with 241 additions and 16 deletions

View file

@ -3,11 +3,13 @@ package main
import (
"fmt"
"net/http"
"net/mail"
"slices"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"jupiter-mail.org/web/model"
)
type Account struct {
@ -15,6 +17,7 @@ type Account struct {
Domain string `json:"domain"`
DisplayName string `json:"display_name"`
MailDirectory string `json:"mail_directory"`
Activated bool `json:"activated"`
}
var mockAccounts = []*Account{
@ -23,18 +26,21 @@ var mockAccounts = []*Account{
Username: "ari",
Domain: "example.org",
MailDirectory: `/var/mail/example.org/ari/home`,
Activated: true,
},
{
DisplayName: "Test Account",
Username: "testaccount",
Domain: "mydomain.ie",
MailDirectory: `/var/mail/mydomain.ie/testaccount/home`,
Activated: false,
},
{
DisplayName: "Cooler One",
Username: "otherone",
Domain: "cooler.space",
MailDirectory: `/var/mail/cooler.space/otherone/home`,
Activated: true,
},
}
@ -43,6 +49,7 @@ func main() {
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowHeaders: []string{"Content-Type"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
MaxAge: 12 * time.Hour,
}))
@ -78,6 +85,67 @@ func main() {
ctx.JSON(http.StatusOK, accounts)
})
r.POST("/api/v1/accounts", func(ctx *gin.Context) {
type Request struct {
DisplayName string `json:"display_name"`
Username string `json:"username"`
Domain string `json:"domain"`
}
var newAccountData Request
if err := ctx.BindJSON(&newAccountData); err != nil {
ctx.String(http.StatusBadRequest, "Malformed request data")
return
}
emailAddressReq := fmt.Sprintf("%s@%s", newAccountData.Username, newAccountData.Domain)
emailAddress, err := mail.ParseAddress(emailAddressReq)
if err != nil {
ctx.String(http.StatusBadRequest, "Malformed address: %s", emailAddressReq)
return
}
search := func(account *Account) bool {
return (
account.Username == newAccountData.Username &&
account.Domain == newAccountData.Domain)
}
if slices.ContainsFunc(mockAccounts, search) {
ctx.String(http.StatusBadRequest, "Account already exists: %s", emailAddress.String())
return
}
account := model.MailAccount{
DisplayName: newAccountData.DisplayName,
Username: newAccountData.Username,
Domain: newAccountData.Domain,
}
account.MailDirectory = account.DefaultMailHome()
mockAccounts = append(mockAccounts, &Account{
DisplayName: account.DisplayName,
Username: account.Username,
Domain: account.Domain,
MailDirectory: account.MailDirectory,
})
/*
account, err := controller.FetchAccountByEmail(email)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
return
}
log.Fatalf("Failed to fetch account: %v", err)
ctx.Error(errors.New("Failed to fetch account"))
return
}
*/
ctx.JSON(http.StatusOK, account)
})
r.GET("/api/v1/accounts/:email", func(ctx *gin.Context) {
email := ctx.Params.ByName("email")
@ -142,10 +210,10 @@ func main() {
ctx.JSON(http.StatusOK, Settings{
MailDelivery: MailDelivery{
Version: "v1.2.3",
Version: "Dovecot v1.2.3",
},
MailTransfer: MailTransfer{
Version: "v4.5.6",
Version: "Postfix v4.5.6",
},
})
})

View file

@ -41,7 +41,7 @@
--info: #1a94ff;
--success: #92a40a;
--warning: #ffc107;
--warning: #e7ad00;
--error: #d42c2c;
}
@ -125,3 +125,12 @@ code {
transition: background-color .1s, border-color .1s;
}
p.warning {
width: fit-content;
padding: .2em .4em;
color: var(--on-bg-1);
background-color: var(--warning);
border: 1px solid color-mix(in srgb, var(--warning), #000 25%);
border-radius: var(--radius-0);
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { type Account } from '@jupiter/lib/account';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import { Copy, User } from '@lucide/svelte';
import { CircleAlert, Copy, User } from '@lucide/svelte';
let {
account,
@ -31,7 +31,7 @@
</script>
<div
class="account"
class={["account", { warning: !account.activated }]}
role="button"
data-email="{email}"
tabindex="0"
@ -41,7 +41,12 @@
<div class="icon-container"><User /></div>
<hr>
<div class="info-container">
<h2 class="name">{account.display_name}</h2>
<h2 class="name">
{#if !account.activated}
<span class="warning-badge"><CircleAlert /></span>
{/if}
{account.display_name}
</h2>
<button class="email" onclick={(event) => {
event.stopPropagation();
copyEmail();
@ -53,6 +58,7 @@
<style>
.account {
--shadow: 0 var(--sp-ssm) var(--sp-sm) #0001;
width: 480px;
height: var(--sp-xxxl);
padding: .5em;
@ -64,13 +70,21 @@
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
box-shadow: 0 var(--sp-ssm) var(--sp-sm) #0001;
box-shadow: var(--shadow);
transition: background-color .1s, border-color .1s;
cursor: pointer;
user-select: none;
}
.account.warning {
box-shadow: var(--shadow), calc(0px - var(--sp-sm)) 0 0 var(--warning);
}
.account .warning-badge {
color: var(--warning);
}
.account:hover {
background-color: var(--bg-1);
border-color: var(--bg-3);
@ -116,10 +130,18 @@
font-size: 1.2em;
line-height: 1.6em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: flex;
align-items: center;
gap: .2em;
}
.info-container .name :global(svg) {
height: 1em;
transform: translateY(.1em);
}
.info-container .email {

View file

@ -5,6 +5,7 @@ export interface Account {
username: string,
domain: string,
mail_directory: string,
activated: boolean,
}
export let accounts = writable<Account[] | null>(null);

View file

@ -3,13 +3,13 @@
import Header from '@jupiter/components/ui/Header.svelte';
import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte';
import AccountAddButton from '@jupiter/components/ui/AccountAddButton.svelte';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import { goto } from '$app/navigation';
let { data } = $props();
const accounts = (() => data.accounts)();
function addAccount() {
pushToast("TODO: account creation", ToastType.INFO);
goto("/accounts/create");
}
</script>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Copy, KeyRound, Mail, Trash, User } from '@lucide/svelte';
import { CircleAlert, Copy, KeyRound, Mail, Trash, User } from '@lucide/svelte';
import Header from '@jupiter/components/ui/Header.svelte';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import Button from '@jupiter/components/ui/Button.svelte';
@ -58,13 +58,22 @@
</div>
<hr>
<h2 class="account-title">
<span>
{#if !account.activated}
<span class="warning-badge"><CircleAlert /></span>
{/if}
{account.display_name}
</span>
<button class="email" onclick={() => { copyEmail() }}>{email} <Copy /></button>
</h2>
</div>
<hr>
{#if !account.activated}
<p class="warning">* This account is not activated yet.</p>
{/if}
<Label>Actions</Label>
<div class="account-actions">
<Button href="mailto:{email}"><Mail /> Send Email</Button>
@ -103,6 +112,10 @@
gap: .2em;
}
.account-title-container .warning-badge {
color: var(--warning);
}
.account-title-icon {
height: 100%;
aspect-ratio: 1;
@ -133,6 +146,12 @@
gap: .2em;
}
.account-title span {
display: inline-flex;
align-items: center;
gap: .25em;
}
.account-title .email {
width: fit-content;
height: 1.5em;

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { UserRoundPlus } from '@lucide/svelte';
import Header from '@jupiter/components/ui/Header.svelte';
import * as api from '@jupiter/lib/api';
import Label from '@jupiter/components/ui/Label.svelte';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import Button from '@jupiter/components/ui/Button.svelte';
import { type Account } from '@jupiter/lib/account';
import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte';
import { goto } from '$app/navigation';
let domainInput: HTMLInputElement;
let newAccount = $state<Account>({
display_name: "",
username: "",
domain: "",
mail_directory: "",
activated: true,
});
async function createAccount() {
const data = {
display_name: newAccount.display_name,
username: newAccount.username,
domain: newAccount.domain,
};
const res = await fetch(api.BASE_URL + "/api/v1/accounts", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
const text = await res.text();
const err = new Error("Failed to create account: " + text);
pushToast(err.message, ToastType.ERROR);
throw err;
}
pushToast("Account created successfully.", ToastType.SUCCESS);
goto("/accounts");
}
// TODO: domain should be a drop-down determined by domain config
</script>
<div class="page-container">
<Header>
<UserRoundPlus />
<h1>Create Account</h1>
</Header>
<main>
<div class="form">
<Label>Display Name</Label>
<input type="text" placeholder="Example User" bind:value={newAccount.display_name}>
<Label>Username</Label>
<input
type="text"
placeholder="user"
bind:value={newAccount.username}
onkeydown={(event: KeyboardEvent) => {
if (event.key === "@") {
event.preventDefault();
domainInput.focus();
}
}}>
<Label>Domain</Label>
<input type="text" placeholder="example.org" bind:value={newAccount.domain} bind:this={domainInput}>
<AccountListItem account={newAccount} />
<Button onclick={() => {createAccount()}}>Create Account</Button>
</div>
</main>
</div>
<style>
.page-container {
min-height: 100%;
background-color: var(--bg-0);
}
main {
padding: 2em;
}
input[type="text"] {
margin-bottom: 1em;
display: block;
font-size: inherit;
padding: .2em .3em;
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
}
.form :global(.account) {
margin-bottom: 1em;
}
</style>