improve web panel; integrate with API server
This commit is contained in:
parent
7d15d70432
commit
c22ab15895
23 changed files with 1031 additions and 115 deletions
1
src/web/backend/.gitignore
vendored
Normal file
1
src/web/backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
tmp/
|
||||
212
src/web/backend/controller/account.go
Normal file
212
src/web/backend/controller/account.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"jupiter-mail.org/web/model"
|
||||
)
|
||||
|
||||
var USERDB_PATH = path.Clean("/etc/dovecot/userdb")
|
||||
const ROOT_UID = 0
|
||||
const DOVECOT_GID = 106
|
||||
|
||||
func OpenUserDB() (*os.File, error) {
|
||||
err := os.MkdirAll(path.Dir(USERDB_PATH), 0755)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
file, err := os.OpenFile(USERDB_PATH, os.O_CREATE | os.O_RDWR, 0640)
|
||||
if err != nil && errors.Is(err, os.ErrExist) { return nil, err }
|
||||
|
||||
if err := file.Chown(ROOT_UID, DOVECOT_GID); err != nil { return nil, err }
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func FetchAccount(username string, domain string) (*model.MailAccount, error) {
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch accounts: %v", err)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
if account.Username == username && account.Domain == domain {
|
||||
return account, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("account does not exist: %s@%s", username, domain)
|
||||
}
|
||||
|
||||
func FetchAccountByEmail(email string) (*model.MailAccount, error) {
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch accounts: %v", err)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
if fmt.Sprintf("%s@%s", account.Username, account.Domain) == email {
|
||||
return account, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("account does not exist: %s@%s", email)
|
||||
}
|
||||
|
||||
func IfAccountExists(username string, domain string) (bool, error) {
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("fetch accounts: %v", err)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
if account.Username == username && account.Domain == domain {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func HashPassword(username string, domain string, password string) ([]byte, error) {
|
||||
// doveadm pw -s SHA512-CRYPT -u "$USERNAME" -p "$PASSWORD"
|
||||
hashCmd := exec.Command(
|
||||
"doveadm", "pw",
|
||||
"-s", "SHA512-CRYPT",
|
||||
"-u", fmt.Sprintf("%s@%s", username, domain),
|
||||
"-p", password,
|
||||
)
|
||||
out, err := hashCmd.Output()
|
||||
if err != nil { return nil, err }
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func WriteAccounts(accounts []*model.MailAccount) (error) {
|
||||
newUserDb := strings.Builder{}
|
||||
for _, account := range accounts {
|
||||
newUserDb.WriteString(account.UserDbLine())
|
||||
}
|
||||
|
||||
if err := os.WriteFile(USERDB_PATH, []byte(newUserDb.String()), 0640); err != nil {
|
||||
return fmt.Errorf("write userdb: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAccount(username string, domain string, password string) (*model.MailAccount, error) {
|
||||
exists, err := IfAccountExists(username, domain)
|
||||
if err != nil { return nil, fmt.Errorf("failed to check if account exists: %v", err) }
|
||||
if exists { return nil, fmt.Errorf("account already exists: %s@%s", username, domain) }
|
||||
|
||||
passwordHash, err := HashPassword(username, domain, password)
|
||||
if err != nil { return nil, fmt.Errorf("hash password: %v", err) }
|
||||
|
||||
account := &model.MailAccount{
|
||||
Username: username,
|
||||
Domain: domain,
|
||||
PasswordHash: []byte(passwordHash),
|
||||
}
|
||||
account.MailDirectory = account.DefaultMailHome()
|
||||
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil { return nil, fmt.Errorf("failed to fetch accounts: %v", err) }
|
||||
accounts = append(accounts, account)
|
||||
|
||||
if err = WriteAccounts(accounts); err != nil {
|
||||
return nil, fmt.Errorf("write accounts: %v", err)
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func FetchAccounts() ([]*model.MailAccount, error) {
|
||||
file, err := OpenUserDB()
|
||||
if err != nil { return nil, fmt.Errorf("open userdb: %v", err) }
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil { return nil, fmt.Errorf("read userdb: %v", err) }
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
accounts := []*model.MailAccount{}
|
||||
for i, line := range lines {
|
||||
if len(line) == 0 { continue }
|
||||
segments := strings.Split(line, ":")
|
||||
if len(segments) != 8 {
|
||||
return nil, fmt.Errorf("malformed data on line %d", i)
|
||||
}
|
||||
|
||||
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/home::
|
||||
fullUsername := segments[0]
|
||||
usernameSplits := strings.Split(fullUsername, "@")
|
||||
if len(usernameSplits) != 2 {
|
||||
return nil, fmt.Errorf("invalid email format on line %d: %s", i, fullUsername)
|
||||
}
|
||||
username := usernameSplits[0]
|
||||
domain := usernameSplits[1]
|
||||
|
||||
passwordHash := []byte(segments[1])
|
||||
mailDir := segments[5]
|
||||
account := &model.MailAccount{
|
||||
Username: username,
|
||||
Domain: domain,
|
||||
PasswordHash: passwordHash,
|
||||
MailDirectory: mailDir,
|
||||
}
|
||||
account.MailDirectory = mailDir
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func UpdateAccount(username string, domain string, password string) (error) {
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil { return fmt.Errorf("fetch accounts: %v", err) }
|
||||
|
||||
var account *model.MailAccount = nil
|
||||
for _, acc := range accounts {
|
||||
if acc.Username == username && acc.Domain == domain {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
if account == nil { return fmt.Errorf("account does not exist: %s@%s", username, domain) }
|
||||
|
||||
passwordHash, err := HashPassword(username, domain, password)
|
||||
if err != nil { return fmt.Errorf("hash password: %v", err) }
|
||||
account.PasswordHash = passwordHash
|
||||
|
||||
WriteAccounts(accounts)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteAccount(username string, domain string) (error) {
|
||||
accounts, err := FetchAccounts()
|
||||
if err != nil { return fmt.Errorf("fetch accounts: %v", err) }
|
||||
|
||||
userDbFile, err := OpenUserDB()
|
||||
if err != nil { return fmt.Errorf("open userdb: %v", err) }
|
||||
defer userDbFile.Close()
|
||||
|
||||
newAccounts := []*model.MailAccount{}
|
||||
for _, account := range accounts {
|
||||
if account.Username != username || account.Domain != domain {
|
||||
newAccounts = append(newAccounts, account)
|
||||
}
|
||||
}
|
||||
|
||||
if err := WriteAccounts(newAccounts); err != nil {
|
||||
return fmt.Errorf("write accounts: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,39 @@
|
|||
module jupiter-mail.org/web
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
|
|
|||
87
src/web/backend/go.sum
Normal file
87
src/web/backend/go.sum
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
Username string `json:"username"`
|
||||
Domain string `json:"domain"`
|
||||
DisplayName string `json:"display_name"`
|
||||
MailDirectory string `json:"mail_directory"`
|
||||
}
|
||||
|
||||
var mockAccounts = []*Account{
|
||||
{
|
||||
DisplayName: "Ari Butterly",
|
||||
Username: "ari",
|
||||
Domain: "example.org",
|
||||
MailDirectory: `/var/mail/example.org/ari/home`,
|
||||
},
|
||||
{
|
||||
DisplayName: "Test Account",
|
||||
Username: "testaccount",
|
||||
Domain: "mydomain.ie",
|
||||
MailDirectory: `/var/mail/mydomain.ie/testaccount/home`,
|
||||
},
|
||||
{
|
||||
DisplayName: "Cooler One",
|
||||
Username: "otherone",
|
||||
Domain: "cooler.space",
|
||||
MailDirectory: `/var/mail/cooler.space/otherone/home`,
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/ping", func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"message": "pong",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/api/v1/dashboard", func(ctx *gin.Context) {
|
||||
type Dashboard struct {
|
||||
NumAccounts int `json:"num_accounts"`
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Dashboard{
|
||||
NumAccounts: len(mockAccounts),
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/api/v1/accounts", func(ctx *gin.Context) {
|
||||
accounts := mockAccounts
|
||||
|
||||
/*
|
||||
accounts, err := controller.FetchAccounts()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch accounts: %v", err)
|
||||
ctx.Error(errors.New("Failed to fetch accounts"))
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
ctx.JSON(http.StatusOK, accounts)
|
||||
})
|
||||
|
||||
r.GET("/api/v1/accounts/:email", func(ctx *gin.Context) {
|
||||
email := ctx.Params.ByName("email")
|
||||
|
||||
search := func(account *Account) bool {
|
||||
return fmt.Sprintf("%s@%s", account.Username, account.Domain) == email
|
||||
}
|
||||
var account *Account = mockAccounts[slices.IndexFunc(mockAccounts, search)]
|
||||
|
||||
if account == nil {
|
||||
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
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.DELETE("/api/v1/accounts/:email", func(ctx *gin.Context) {
|
||||
email := ctx.Params.ByName("email")
|
||||
|
||||
search := func(account *Account) bool {
|
||||
return fmt.Sprintf("%s@%s", account.Username, account.Domain) == email
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(mockAccounts, search) {
|
||||
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
|
||||
return
|
||||
}
|
||||
|
||||
mockAccounts = slices.DeleteFunc(mockAccounts, search)
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
r.GET("/api/v1/settings", func(ctx *gin.Context) {
|
||||
type (
|
||||
MailDelivery struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
MailTransfer struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
Settings struct {
|
||||
MailDelivery MailDelivery `json:"mail_delivery"`
|
||||
MailTransfer MailTransfer `json:"mail_transfer"`
|
||||
}
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, Settings{
|
||||
MailDelivery: MailDelivery{
|
||||
Version: "v1.2.3",
|
||||
},
|
||||
MailTransfer: MailTransfer{
|
||||
Version: "v4.5.6",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
r.Run()
|
||||
}
|
||||
40
src/web/backend/model/account.go
Normal file
40
src/web/backend/model/account.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
MailAccount struct {
|
||||
Username string `json:"username"`
|
||||
Domain string `json:"domain"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PasswordHash []byte `json:"password"`
|
||||
MailDirectory string `json:"mail_dir"`
|
||||
}
|
||||
)
|
||||
|
||||
func (account *MailAccount) FullUsername() string {
|
||||
return fmt.Sprintf("%s@%s", account.Username, account.Domain)
|
||||
}
|
||||
|
||||
func (account *MailAccount) UserDbLine() string {
|
||||
uid := 5000; gid := 5000
|
||||
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/home::
|
||||
return fmt.Sprintf(
|
||||
"%s:%s:%d:%d::%s::\n",
|
||||
account.FullUsername(),
|
||||
strings.Trim(string(account.PasswordHash), " \n"),
|
||||
uid, gid,
|
||||
account.DefaultMailHome(),
|
||||
)
|
||||
}
|
||||
|
||||
func (account *MailAccount) DefaultMailHome() string {
|
||||
return fmt.Sprintf(
|
||||
"/var/mail/%s/%s/home",
|
||||
account.Domain,
|
||||
account.Username,
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
--sp-xxxl: var(--sp-64);
|
||||
|
||||
--sz-member-list: 270px;
|
||||
--skeleton-gradient-width: 500px;
|
||||
|
||||
--radius-0: var(--sp-sm);
|
||||
--radius-1: calc(var(--radius-0) * 2);
|
||||
|
|
@ -69,3 +70,58 @@ a {
|
|||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--sp-sm);
|
||||
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-1),
|
||||
var(--bg-0),
|
||||
var(--bg-1)
|
||||
);
|
||||
background-size: var(--skeleton-gradient-width) 100%;
|
||||
background-repeat: repeat;
|
||||
|
||||
animation: skeleton 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes throb {
|
||||
from { opacity: 1 }
|
||||
to { opacity: .25 }
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: var(--skeleton-gradient-width) 0;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
width: fit-content;
|
||||
height: 1.5em;
|
||||
|
||||
margin: 0;
|
||||
padding: 0 .2em;
|
||||
|
||||
font-family: monospace;
|
||||
font-weight: normal;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
color: var(--on-bg-3);
|
||||
background-color: var(--bg-1);
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--radius-0);
|
||||
|
||||
transition: background-color .1s, border-color .1s;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@
|
|||
account: Account,
|
||||
}>();
|
||||
|
||||
let email: string = $state("");
|
||||
$effect(() => {
|
||||
email = `${account.username}@${account.domain}`;
|
||||
});
|
||||
let link: HTMLAnchorElement;
|
||||
|
||||
function onPress(event: MouseEvent | KeyboardEvent) {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return;
|
||||
if (event instanceof KeyboardEvent && event.key !== "Enter") return;
|
||||
link.click();
|
||||
}
|
||||
|
||||
async function copyEmail() {
|
||||
await navigator.clipboard.write([new ClipboardItem({
|
||||
"text/plain": `${account.username}@${account.domain}`,
|
||||
|
|
@ -18,13 +30,23 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="account" role="button">
|
||||
<div
|
||||
class="account"
|
||||
role="button"
|
||||
data-email="{email}"
|
||||
tabindex="0"
|
||||
onclick={onPress}
|
||||
onkeydown={onPress}>
|
||||
<a href="/accounts/{email}" hidden bind:this={link}>{email}</a>
|
||||
<div class="icon-container"><User /></div>
|
||||
<hr>
|
||||
<div class="info-container">
|
||||
<h2 class="name">{account.username}</h2>
|
||||
<button class="email" onclick={() => {copyEmail()}}>
|
||||
{account.username}@{account.domain}<Copy />
|
||||
<h2 class="name">{account.display_name}</h2>
|
||||
<button class="email" onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyEmail();
|
||||
}}>
|
||||
{email}<Copy />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,12 +81,15 @@
|
|||
}
|
||||
|
||||
.icon-container {
|
||||
width: var(--sp-xxxl);
|
||||
min-width: var(--sp-xxxl);
|
||||
height: var(--sp-xxxl);
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-color: var(--bg-1);
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--radius-0);
|
||||
}
|
||||
|
||||
.icon-container :global(svg) {
|
||||
|
|
@ -99,17 +124,15 @@
|
|||
|
||||
.info-container .email {
|
||||
width: fit-content;
|
||||
height: 1.5em;
|
||||
|
||||
margin: 0;
|
||||
padding: .2em;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: monospace;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.6em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -134,6 +157,7 @@
|
|||
}
|
||||
|
||||
.info-container .email :global(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-left: var(--sp-sm);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
children,
|
||||
href,
|
||||
} = $props<{
|
||||
let { children, onclick, href, class: className } = $props<{
|
||||
children: () => any,
|
||||
href: string
|
||||
onclick?: Function,
|
||||
href?: string,
|
||||
class?: string,
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<a href="{href}">{@render children()}</a>
|
||||
{#if href}
|
||||
<a href="{href}" class="button {className}" onclick={onclick}><span>{@render children()}</span></a>
|
||||
{:else}
|
||||
<button onclick={onclick} class="{className}"><span>{@render children()}</span></button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
a {
|
||||
button, .button {
|
||||
margin-left: var(--sp-sm);
|
||||
padding: .5em;
|
||||
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
font-size: 1em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
|
@ -35,11 +39,17 @@
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
button:hover, .button:hover {
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
|
||||
a:active {
|
||||
button:active, .button:active {
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
|
||||
button :global(svg), .button :global(svg) {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
margin: 0 .1em -.175em 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { type Icon } from '@lucide/svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -24,4 +22,9 @@
|
|||
header :global(h1) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header :global(svg) {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
17
src/web/frontend/src/components/ui/Label.svelte
Normal file
17
src/web/frontend/src/components/ui/Label.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<p class="label">{@render children()}</p>
|
||||
|
||||
<style>
|
||||
.label {
|
||||
margin: 1.5em 0 .5em .2em;
|
||||
|
||||
font-size: .8em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
|
||||
opacity: .66;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<div class="skeleton"><slot></slot></div>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--width: 500px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--sp-sm);
|
||||
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-1),
|
||||
var(--bg-0),
|
||||
var(--bg-1)
|
||||
);
|
||||
background-size: var(--width) 100%;
|
||||
background-repeat: repeat;
|
||||
|
||||
animation: skeleton 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes throb {
|
||||
from { opacity: 1 }
|
||||
to { opacity: .25 }
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { writable } from "svelte/store";
|
||||
|
||||
export default interface Account {
|
||||
export interface Account {
|
||||
display_name: string,
|
||||
username: string,
|
||||
domain: string,
|
||||
mail_directory: string,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,55 @@
|
|||
import type { Account } from "./accounts.ts";
|
||||
let token = "";
|
||||
import type { Account } from "@jupiter/lib/account";
|
||||
|
||||
export function fetchAccounts(): Promise<Account[]> {
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res([
|
||||
{
|
||||
username: "ari",
|
||||
domain: "example.org",
|
||||
mail_directory: `/var/mail/example.org/ari/home`,
|
||||
},
|
||||
{
|
||||
username: "testbanger",
|
||||
domain: "gmail.com",
|
||||
mail_directory: `/var/mail/example.org/ari/home`,
|
||||
}
|
||||
]);
|
||||
}, 300)
|
||||
})
|
||||
export const BASE_URL = "http://localhost:8080";
|
||||
|
||||
export async function fetchDashboard(): Promise<any> {
|
||||
return new Promise(async (ok, err) => {
|
||||
fetch(BASE_URL + "/api/v1/dashboard")
|
||||
.then(res => {
|
||||
res.json().then(json => {
|
||||
ok(json);
|
||||
});
|
||||
}).catch(e => {
|
||||
err(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAccounts(): Promise<Account[]> {
|
||||
return new Promise((ok, err) => {
|
||||
fetch(BASE_URL + "/api/v1/accounts")
|
||||
.then(res => {
|
||||
res.json().then(accounts => {
|
||||
ok(accounts as Account[]);
|
||||
});
|
||||
}).catch(e => {
|
||||
err(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAccountByEmail(email: string): Promise<Account> {
|
||||
return new Promise((ok, err) => {
|
||||
fetch(BASE_URL + "/api/v1/accounts/" + email)
|
||||
.then(res => {
|
||||
res.json().then(account => {
|
||||
ok(account as Account);
|
||||
});
|
||||
}).catch(e => {
|
||||
err(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<any> {
|
||||
return new Promise((ok, err) => {
|
||||
fetch(BASE_URL + "/api/v1/settings")
|
||||
.then(res => {
|
||||
res.json().then(json => {
|
||||
ok(json);
|
||||
});
|
||||
}).catch(e => {
|
||||
err(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
import '@jupiter/app.css';
|
||||
import favicon from '@jupiter/assets/favicon.svg';
|
||||
import { LayoutDashboard, Mail, Settings, User } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import ToastOverlay from '@jupiter/components/ui/ToastOverlay.svelte';
|
||||
import SidebarLink from '@jupiter/components/ui/SidebarLink.svelte';
|
||||
import Button from '@jupiter/components/ui/Button.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
|
@ -16,9 +15,9 @@
|
|||
<div class="app">
|
||||
<header class="sidebar">
|
||||
<h1><Mail /> Jupiter Mail</h1>
|
||||
<SidebarLink href="/"><LayoutDashboard /> Dashboard</SidebarLink>
|
||||
<SidebarLink href="/accounts"><User /> Accounts</SidebarLink>
|
||||
<SidebarLink href="/settings"><Settings /> Settings</SidebarLink>
|
||||
<Button href="/"><LayoutDashboard /> Dashboard</Button>
|
||||
<Button href="/accounts"><User /> Accounts</Button>
|
||||
<Button href="/settings"><Settings /> Settings</Button>
|
||||
</header>
|
||||
<main>
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { LayoutDashboard, User } from '@lucide/svelte';
|
||||
import { LayoutDashboard } from '@lucide/svelte';
|
||||
import Card from '@jupiter/components/ui/Card.svelte';
|
||||
import Header from '@jupiter/components/ui/Header.svelte';
|
||||
import * as api from '@jupiter/lib/api';
|
||||
import { type Account, accounts } from '@jupiter/lib/account';
|
||||
|
||||
onMount(async () => {
|
||||
if ($accounts == null) api.fetchAccounts().then(accs => {
|
||||
accounts.update(() => accs);
|
||||
});
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="page-container">
|
||||
|
|
@ -22,7 +15,7 @@
|
|||
<main>
|
||||
<Card>
|
||||
<h2><a href="/accounts">Accounts</a></h2>
|
||||
<p class="active-accounts">Active: {$accounts.length}</p>
|
||||
<p class="active-accounts">Active: {data.dashboard.num_accounts}</p>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
16
src/web/frontend/src/routes/+page.ts
Normal file
16
src/web/frontend/src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as api from '@jupiter/lib/api';
|
||||
import { error, isHttpError } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ fetch }: { fetch: Function }) {
|
||||
try {
|
||||
const res = await fetch(api.BASE_URL + "/api/v1/dashboard");
|
||||
const json = await res.json();
|
||||
return { dashboard: json }
|
||||
} catch(err) {
|
||||
if (isHttpError(err)) {
|
||||
error(err.status, err.body);
|
||||
} else {
|
||||
error(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { User } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import * as api from '@jupiter/lib/api';
|
||||
import { type Account, accounts } from '@jupiter/lib/account';
|
||||
import { pushToast, ToastType } from '@jupiter/lib/toasts';
|
||||
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 Skeleton from '@jupiter/components/ui/Skeleton.svelte';
|
||||
import { pushToast, ToastType } from '@jupiter/lib/toasts';
|
||||
|
||||
onMount(async () => {
|
||||
if ($accounts == null) api.fetchAccounts().then(accs => {
|
||||
accounts.update(() => accs);
|
||||
});
|
||||
});
|
||||
let { data } = $props();
|
||||
const accounts = (() => data.accounts)();
|
||||
|
||||
function addAccount() {
|
||||
alert("TODO: this");
|
||||
pushToast("TODO: account creation", ToastType.INFO);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -30,9 +23,9 @@
|
|||
<p>Click an account below to configure:</p>
|
||||
<hr>
|
||||
<div class="account-list">
|
||||
{#if $accounts}
|
||||
{#each $accounts as account}
|
||||
<AccountListItem account={account} />
|
||||
{#if accounts}
|
||||
{#each accounts as account}
|
||||
<AccountListItem account={account}/>
|
||||
{/each}
|
||||
<AccountAddButton
|
||||
onclick={() => { addAccount() }}
|
||||
|
|
@ -41,8 +34,8 @@
|
|||
}}
|
||||
/>
|
||||
{:else}
|
||||
{#each {length: 4} as i}
|
||||
<Skeleton><div class="account-skeleton"></div></Skeleton>
|
||||
{#each {length: 4}}
|
||||
<div class="skeleton"></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -70,7 +63,7 @@
|
|||
gap: 1em;
|
||||
}
|
||||
|
||||
.account-skeleton {
|
||||
.account-list .skeleton {
|
||||
width: 480px;
|
||||
height: var(--sp-xxxl);
|
||||
padding: .5em;
|
||||
|
|
|
|||
17
src/web/frontend/src/routes/accounts/+page.ts
Normal file
17
src/web/frontend/src/routes/accounts/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Account } from "@jupiter/lib/account";
|
||||
import * as api from "@jupiter/lib/api";
|
||||
import { error, isHttpError } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ fetch }: { fetch: Function }) {
|
||||
try {
|
||||
const res = await fetch(api.BASE_URL + "/api/v1/accounts");
|
||||
const json = await res.json();
|
||||
return { accounts: json as Account[] }
|
||||
} catch(err: any) {
|
||||
if (isHttpError(err)) {
|
||||
error(err.status, err.body);
|
||||
} else {
|
||||
error(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/web/frontend/src/routes/accounts/[email]/+page.svelte
Normal file
189
src/web/frontend/src/routes/accounts/[email]/+page.svelte
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { 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';
|
||||
import Label from '@jupiter/components/ui/Label.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '@jupiter/lib/api';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const account = (() => data.account)();
|
||||
const email = (() => data.email)();
|
||||
|
||||
async function copyEmail() {
|
||||
await navigator.clipboard.write([new ClipboardItem({
|
||||
"text/plain": email,
|
||||
})]);
|
||||
|
||||
pushToast("Email copied to clipboard.", ToastType.SUCCESS);
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
pushToast("TODO: password reset", ToastType.INFO);
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (prompt(
|
||||
"Are you sure you wish to delete this account? " +
|
||||
"This action is irreversible.\n\n" +
|
||||
`To confirm, please enter ${email}:`) !== email) return;
|
||||
|
||||
const res = await fetch(api.BASE_URL + "/api/v1/accounts/" + email, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
pushToast("Failed to delete account: " + text, ToastType.ERROR);
|
||||
return
|
||||
}
|
||||
|
||||
pushToast("Account deleted successfully.", ToastType.SUCCESS);
|
||||
goto("/accounts");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page-container">
|
||||
<Header>
|
||||
<User />
|
||||
<h1>Account Configuration</h1>
|
||||
</Header>
|
||||
|
||||
<main>
|
||||
{#if account}
|
||||
<div class="account-title-container">
|
||||
<div class="account-title-icon">
|
||||
<User />
|
||||
</div>
|
||||
<hr>
|
||||
<h2 class="account-title">
|
||||
{account.display_name}
|
||||
<button class="email" onclick={() => { copyEmail() }}>{email} <Copy /></button>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<Label>Actions</Label>
|
||||
<div class="account-actions">
|
||||
<Button href="mailto:{email}"><Mail /> Send Email</Button>
|
||||
<Button onclick={() => resetPassword()}><KeyRound /> Reset Password</Button>
|
||||
<Button onclick={() => deleteAccount()} class="delete">
|
||||
<Trash /> Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Label>Details</Label>
|
||||
<div class="account-details">
|
||||
<p>Mail Directory: <code>{account.mail_directory}</code></p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Just a moment...</p>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
min-height: 100%;
|
||||
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.account-title-container {
|
||||
height: var(--sp-xxxl);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: .2em;
|
||||
}
|
||||
|
||||
.account-title-icon {
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--bg-1);
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--radius-0);
|
||||
}
|
||||
.account-title-icon :global(svg) {
|
||||
--size: 60%;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
.account-title-container hr {
|
||||
height: 100%;
|
||||
margin: 0 .5em;
|
||||
border: none;
|
||||
border-left: 1px solid var(--bg-2);
|
||||
}
|
||||
|
||||
.account-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .2em;
|
||||
}
|
||||
|
||||
.account-title .email {
|
||||
width: fit-content;
|
||||
height: 1.5em;
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-family: monospace;
|
||||
font-size: .8em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
color: var(--on-bg-3);
|
||||
background-color: var(--bg-1);
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: var(--radius-0);
|
||||
|
||||
transition: background-color .1s, border-color .1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-title .email:hover {
|
||||
background-color: var(--bg-2);
|
||||
border-color: var(--bg-3);
|
||||
}
|
||||
|
||||
.account-title .email:active {
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
|
||||
.account-title .email :global(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-left: var(--sp-sm);
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: .5em;
|
||||
}
|
||||
.account-actions :global(button.delete) {
|
||||
box-shadow: calc(0px - var(--sp-sm)) 0 0 var(--error);
|
||||
}
|
||||
|
||||
.account-details p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
21
src/web/frontend/src/routes/accounts/[email]/+page.ts
Normal file
21
src/web/frontend/src/routes/accounts/[email]/+page.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Account } from "@jupiter/lib/account";
|
||||
import * as api from "@jupiter/lib/api";
|
||||
import { error, isHttpError } from "@sveltejs/kit";
|
||||
|
||||
export async function load({ fetch, params }: { fetch: Function, params: any }) {
|
||||
try {
|
||||
const res = await fetch(api.BASE_URL + "/api/v1/accounts/" + params.email);
|
||||
const json = await res.json();
|
||||
|
||||
const account = json as Account;
|
||||
const email = `${account.username}@${account.domain}`;
|
||||
|
||||
return { account, email }
|
||||
} catch(err: any) {
|
||||
if (isHttpError(err)) {
|
||||
error(err.status, err.body);
|
||||
} else {
|
||||
error(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import Header from '@jupiter/components/ui/Header.svelte';
|
||||
import Card from '@jupiter/components/ui/Card.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let settings = (() => data.settings)();
|
||||
</script>
|
||||
|
||||
<div class="page-container">
|
||||
|
|
@ -8,6 +13,18 @@
|
|||
<Settings />
|
||||
<h1>Settings</h1>
|
||||
</Header>
|
||||
|
||||
<main>
|
||||
<Card>
|
||||
<h2>Mail Delivery Settings</h2>
|
||||
<p>Running: <code>{settings.mail_delivery.version}</code></p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2>Mail Transfer Settings</h2>
|
||||
<p>Running: <code>{settings.mail_transfer.version}</code></p>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -16,4 +33,21 @@
|
|||
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 .5em 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
16
src/web/frontend/src/routes/settings/+page.ts
Normal file
16
src/web/frontend/src/routes/settings/+page.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as api from '@jupiter/lib/api';
|
||||
import { error, isHttpError } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ fetch }: { fetch: Function }) {
|
||||
try {
|
||||
const res = await fetch(api.BASE_URL + "/api/v1/settings");
|
||||
const json = await res.json();
|
||||
return { settings: json }
|
||||
} catch(err) {
|
||||
if (isHttpError(err)) {
|
||||
error(err.status, err.body);
|
||||
} else {
|
||||
error(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue