feat: notify user on account deletion
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s
This commit is contained in:
parent
2db260f849
commit
43e6f76c01
12 changed files with 294 additions and 19 deletions
|
|
@ -58,7 +58,14 @@ func New(cfg *config.Config) (*App, error) {
|
|||
accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database)
|
||||
|
||||
// Services
|
||||
userService := service.NewUserService(database, userRepository, accountDeletionRequestRepo)
|
||||
emailService := service.NewEmailService(
|
||||
emailClient,
|
||||
cfg.MailerEmailFrom,
|
||||
cfg.AppURL,
|
||||
cfg.AppName,
|
||||
cfg.IsProduction(),
|
||||
)
|
||||
userService := service.NewUserService(database, userRepository, accountDeletionRequestRepo, emailService)
|
||||
accountDeletionWorker := worker.NewAccountDeletionWorker(userService, 30*time.Second)
|
||||
auditLogService := service.NewSpaceAuditLogService(auditLogRepository)
|
||||
txAuditLogService := service.NewTransactionAuditLogService(txAuditLogRepository)
|
||||
|
|
@ -73,13 +80,6 @@ func New(cfg *config.Config) (*App, error) {
|
|||
transactionService.SetAuditLogger(txAuditLogService)
|
||||
transactionService.SetAllocationService(allocationService)
|
||||
accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService)
|
||||
emailService := service.NewEmailService(
|
||||
emailClient,
|
||||
cfg.MailerEmailFrom,
|
||||
cfg.AppURL,
|
||||
cfg.AppName,
|
||||
cfg.IsProduction(),
|
||||
)
|
||||
authService := service.NewAuthService(
|
||||
emailService,
|
||||
userRepository,
|
||||
|
|
|
|||
|
|
@ -77,13 +77,36 @@ func (h *settingsHandler) DeleteAccount(w http.ResponseWriter, r *http.Request)
|
|||
http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *settingsHandler) AccountDeletionStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := r.PathValue("requestID")
|
||||
if requestID == "" {
|
||||
ui.Render(w, r, pages.NotFound())
|
||||
return
|
||||
}
|
||||
|
||||
req, err := h.userService.GetDeletionRequest(requestID)
|
||||
if err != nil {
|
||||
slog.Info("account deletion status lookup failed",
|
||||
"request_id", requestID,
|
||||
"error", err,
|
||||
)
|
||||
ui.Render(w, r, pages.NotFound())
|
||||
return
|
||||
}
|
||||
ui.Render(w, r, pages.AccountDeletionStatus(req))
|
||||
}
|
||||
|
||||
func (h *settingsHandler) AccountPendingDeletionPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := ctxkeys.User(r.Context())
|
||||
if user == nil || !user.IsPendingDeletion() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
|
||||
trackingID := ""
|
||||
if req, err := h.userService.LatestDeletionRequestForUser(user.ID); err == nil {
|
||||
trackingID = req.ID
|
||||
}
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, trackingID))
|
||||
}
|
||||
|
||||
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut
|
|||
accountSvc := service.NewAccountService(accountRepo)
|
||||
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
|
||||
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false, false)
|
||||
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
|
||||
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
|
||||
return NewSettingsHandler(authSvc, userSvc), authSvc
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ func BlockPendingDeletion(next http.Handler) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Always permit static assets so the pending page can render.
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
// Always permit static assets so the pending page can render, and
|
||||
// the dynamic deletion-status URL the user got in their email.
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/account-deletion-status/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -48,10 +50,10 @@ func BlockPendingDeletion(next http.Handler) http.HandlerFunc {
|
|||
// Safe methods are redirected to the pending-deletion landing page.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, ""))
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
|
||||
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, ""))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ var ErrAccountDeletionRequestNotFound = errors.New("account deletion request not
|
|||
|
||||
type AccountDeletionRequestRepository interface {
|
||||
CreateTx(tx *sqlx.Tx, req *model.AccountDeletionRequest) error
|
||||
ByID(id string) (*model.AccountDeletionRequest, error)
|
||||
HasPendingForUser(userID string) (bool, error)
|
||||
LatestForUser(userID string) (*model.AccountDeletionRequest, error)
|
||||
|
||||
// ClaimNextPending atomically transitions the oldest pending request to
|
||||
// "processing" and returns it. Returns ErrAccountDeletionRequestNotFound
|
||||
|
|
@ -56,6 +58,36 @@ func (r *accountDeletionRequestRepository) CreateTx(tx *sqlx.Tx, req *model.Acco
|
|||
return err
|
||||
}
|
||||
|
||||
func (r *accountDeletionRequestRepository) ByID(id string) (*model.AccountDeletionRequest, error) {
|
||||
var req model.AccountDeletionRequest
|
||||
err := r.db.Get(&req, `SELECT * FROM account_deletion_requests WHERE id = $1;`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountDeletionRequestNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (r *accountDeletionRequestRepository) LatestForUser(userID string) (*model.AccountDeletionRequest, error) {
|
||||
var req model.AccountDeletionRequest
|
||||
err := r.db.Get(&req,
|
||||
`SELECT * FROM account_deletion_requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT 1;`,
|
||||
userID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountDeletionRequestNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (r *accountDeletionRequestRepository) HasPendingForUser(userID string) (bool, error) {
|
||||
var n int
|
||||
err := r.db.Get(&n,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
r.Get("/privacy", homeH.PrivacyPage).Name("page.public.privacy")
|
||||
r.Get("/terms", homeH.TermsPage).Name("page.public.terms")
|
||||
r.Get("/join/{token}", authH.JoinSpace).Name("page.public.join-space")
|
||||
r.Get("/account-deletion-status/{requestID}", settingsH.AccountDeletionStatusPage).Name("page.public.account-deletion-status")
|
||||
r.Post("/join/{token}/accept", authH.AcceptInvite).Name("action.public.join-space.accept")
|
||||
|
||||
// Permanent redirects
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func newTestApp(dbi testutil.DBInfo) *app.App {
|
|||
accountSvc := service.NewAccountService(accountRepo)
|
||||
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
|
||||
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false, false)
|
||||
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
|
||||
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
|
||||
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
|
||||
|
||||
return &app.App{
|
||||
|
|
|
|||
|
|
@ -224,6 +224,56 @@ func (s *EmailService) SendInvitationEmail(email, spaceName, inviterName, token
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *EmailService) SendAccountDeletionRequestedEmail(email, name, requestID string) error {
|
||||
trackURL := fmt.Sprintf("%s/account-deletion-status/%s", s.appURL, requestID)
|
||||
subject, body := accountDeletionRequestedEmailTemplate(name, trackURL, s.appName)
|
||||
|
||||
if !s.isProd {
|
||||
slog.Info("email sent (dev mode)", "type", "account_deletion_requested", "to", email, "subject", subject, "url", trackURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.client == nil {
|
||||
return fmt.Errorf("email service not configured")
|
||||
}
|
||||
|
||||
params := &EmailParams{
|
||||
From: s.fromEmail,
|
||||
To: []string{email},
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
}
|
||||
|
||||
_, err := s.client.SendWithContext(context.Background(), params)
|
||||
if err == nil {
|
||||
slog.Info("email sent", "type", "account_deletion_requested", "to", email)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func accountDeletionRequestedEmailTemplate(name, trackURL, appName string) (string, string) {
|
||||
greeting := "Hi,"
|
||||
if name != "" {
|
||||
greeting = fmt.Sprintf("Hi %s,", name)
|
||||
}
|
||||
subject := fmt.Sprintf("Your %s account deletion request was received", appName)
|
||||
body := fmt.Sprintf(`%s
|
||||
|
||||
We received your request to permanently delete your %s account. The deletion is now in progress and typically finishes within a few minutes.
|
||||
|
||||
You can track the status of your request here:
|
||||
%s
|
||||
|
||||
This link is the only way to check on the request — keep it somewhere safe if you want to confirm completion later. Once the deletion finishes, your data is gone for good and cannot be recovered.
|
||||
|
||||
If you did NOT request this, contact our support team immediately. We may still be able to halt the deletion before it completes.
|
||||
|
||||
Best,
|
||||
The %s Team`, greeting, appName, trackURL, appName)
|
||||
|
||||
return subject, body
|
||||
}
|
||||
|
||||
func magicLinkEmailTemplate(magicURL, appName string) (string, string) {
|
||||
subject := fmt.Sprintf("Sign in to %s", appName)
|
||||
body := fmt.Sprintf(`Click this link to sign in to your account:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type UserService struct {
|
|||
db *sqlx.DB
|
||||
userRepository repository.UserRepository
|
||||
deletionRequestRepo repository.AccountDeletionRequestRepository
|
||||
emailService *EmailService
|
||||
// triggerDeletion is set by the worker so that handlers can wake the
|
||||
// worker up immediately after enqueueing a new request, instead of
|
||||
// waiting for the next periodic tick.
|
||||
|
|
@ -36,14 +37,29 @@ func NewUserService(
|
|||
db *sqlx.DB,
|
||||
userRepository repository.UserRepository,
|
||||
deletionRequestRepo repository.AccountDeletionRequestRepository,
|
||||
emailService *EmailService,
|
||||
) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
userRepository: userRepository,
|
||||
deletionRequestRepo: deletionRequestRepo,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeletionRequest fetches a deletion request by ID. Returns
|
||||
// repository.ErrAccountDeletionRequestNotFound when the ID is unknown.
|
||||
func (s *UserService) GetDeletionRequest(id string) (*model.AccountDeletionRequest, error) {
|
||||
return s.deletionRequestRepo.ByID(id)
|
||||
}
|
||||
|
||||
// LatestDeletionRequestForUser returns the most recent deletion request for
|
||||
// the given user, or repository.ErrAccountDeletionRequestNotFound if there
|
||||
// is none.
|
||||
func (s *UserService) LatestDeletionRequestForUser(userID string) (*model.AccountDeletionRequest, error) {
|
||||
return s.deletionRequestRepo.LatestForUser(userID)
|
||||
}
|
||||
|
||||
func (s *UserService) SetDeletionTrigger(ch chan<- struct{}) {
|
||||
s.triggerDeletion = ch
|
||||
}
|
||||
|
|
@ -114,6 +130,18 @@ func (s *UserService) RequestAccountDeletion(input RequestAccountDeletionInput)
|
|||
return err
|
||||
}
|
||||
|
||||
// Confirmation email — best-effort. The deletion proceeds regardless so
|
||||
// a transient mail outage doesn't trap the user with a flagged account.
|
||||
if s.emailService != nil {
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
if err := s.emailService.SendAccountDeletionRequestedEmail(user.Email, name, req.ID); err != nil {
|
||||
slog.Error("failed to send account deletion confirmation email", "error", err, "user_id", user.ID, "request_id", req.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Wake the worker so it picks up immediately rather than waiting for the
|
||||
// next tick. Non-blocking so a busy/unbuffered channel never stalls the
|
||||
// HTTP request.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
func TestUserService_ByID(t *testing.T) {
|
||||
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
|
||||
userRepo := repository.NewUserRepository(dbi.DB)
|
||||
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
|
||||
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
|
||||
|
||||
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ func TestUserService_ByID(t *testing.T) {
|
|||
func TestUserService_ByID_NotFound(t *testing.T) {
|
||||
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
|
||||
userRepo := repository.NewUserRepository(dbi.DB)
|
||||
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
|
||||
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
|
||||
|
||||
_, err := svc.ByID("nonexistent-id")
|
||||
assert.Error(t, err)
|
||||
|
|
|
|||
121
internal/ui/pages/account_deletion_status.templ
Normal file
121
internal/ui/pages/account_deletion_status.templ
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
func deletionBadgeClass(status string) string {
|
||||
switch status {
|
||||
case model.AccountDeletionStatusCompleted:
|
||||
return "bg-primary text-primary-foreground"
|
||||
case model.AccountDeletionStatusFailed:
|
||||
return "bg-destructive text-destructive-foreground"
|
||||
case model.AccountDeletionStatusProcessing:
|
||||
return "bg-secondary text-secondary-foreground"
|
||||
default:
|
||||
return "border border-input"
|
||||
}
|
||||
}
|
||||
|
||||
func deletionBadgeText(status string) string {
|
||||
switch status {
|
||||
case model.AccountDeletionStatusCompleted:
|
||||
return "Completed"
|
||||
case model.AccountDeletionStatusFailed:
|
||||
return "Failed"
|
||||
case model.AccountDeletionStatusProcessing:
|
||||
return "Processing"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
func deletionBlurb(status string) string {
|
||||
switch status {
|
||||
case model.AccountDeletionStatusCompleted:
|
||||
return "Your data has been permanently deleted. This record is the only thing that remains."
|
||||
case model.AccountDeletionStatusFailed:
|
||||
return "We hit a problem we couldn't recover from automatically. Your data is still in place — please reach out to support so we can finish the deletion manually."
|
||||
case model.AccountDeletionStatusProcessing:
|
||||
return "Your data is being deleted right now. This usually finishes within a few seconds."
|
||||
default:
|
||||
return "Your request is in the queue. The background worker picks it up within 30 seconds."
|
||||
}
|
||||
}
|
||||
|
||||
templ AccountDeletionStatus(req *model.AccountDeletionRequest) {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
@layouts.Auth(layouts.SEOProps{
|
||||
Title: "Account Deletion Status",
|
||||
Description: "Track the status of an account deletion request",
|
||||
Path: "/account-deletion-status",
|
||||
}) {
|
||||
<div class="container max-w-xl px-4 py-16 mx-auto">
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title(card.TitleProps{Class: "flex items-center gap-2"}) {
|
||||
@icon.Trash2()
|
||||
<span>Account Deletion Status</span>
|
||||
}
|
||||
@card.Description() {
|
||||
Request for { req.Email }
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Status</dt>
|
||||
<dd>
|
||||
<span class={ "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium", deletionBadgeClass(req.Status) }>
|
||||
{ deletionBadgeText(req.Status) }
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Requested</dt>
|
||||
<dd>{ req.RequestedAt.Format("January 2, 2006 at 3:04 PM MST") }</dd>
|
||||
</div>
|
||||
if req.CompletedAt != nil {
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Completed</dt>
|
||||
<dd>{ req.CompletedAt.Format("January 2, 2006 at 3:04 PM MST") }</dd>
|
||||
</div>
|
||||
}
|
||||
if req.Attempts > 0 {
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Attempts</dt>
|
||||
<dd>{ strconv.Itoa(req.Attempts) }</dd>
|
||||
</div>
|
||||
}
|
||||
if req.SpacesDeleted != nil {
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-muted-foreground">Spaces deleted</dt>
|
||||
<dd>{ strconv.Itoa(*req.SpacesDeleted) }</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
<p class="mt-6 text-sm text-muted-foreground">{ deletionBlurb(req.Status) }</p>
|
||||
if req.LastError != nil {
|
||||
<p class="mt-4 text-sm text-destructive">Last error: { *req.LastError }</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">Please contact support at <a href={ templ.URL("mailto:" + cfg.SupportEmail) } class="text-primary hover:underline">{ cfg.SupportEmail }</a></p>
|
||||
}
|
||||
}
|
||||
@card.Footer(card.FooterProps{Class: "justify-between"}) {
|
||||
@button.Button(button.Props{Href: "/", Variant: button.VariantGhost}) {
|
||||
Back to home
|
||||
}
|
||||
@button.Button(button.Props{Href: "/account-deletion-status/" + req.ID, Variant: button.VariantOutline}) {
|
||||
Refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ AccountPendingDeletion(requestedAt time.Time) {
|
||||
templ AccountPendingDeletion(requestedAt time.Time, trackingID string) {
|
||||
@layouts.Auth(layouts.SEOProps{
|
||||
Title: "Account Pending Deletion",
|
||||
Description: "Your account is being deleted",
|
||||
|
|
@ -34,8 +34,26 @@ templ AccountPendingDeletion(requestedAt time.Time) {
|
|||
<p class="text-sm text-muted-foreground mt-4">
|
||||
If you believe this was a mistake, please contact support immediately — we may be able to halt the deletion before it completes.
|
||||
</p>
|
||||
if trackingID != "" {
|
||||
<p class="text-sm text-muted-foreground mt-4">
|
||||
We also emailed you a confirmation. You can check the status of your deletion any time at
|
||||
<a class="text-primary hover:underline break-all" href={ templ.SafeURL("/account-deletion-status/" + trackingID) }>
|
||||
/account-deletion-status/{ trackingID }
|
||||
</a>.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@card.Footer(card.FooterProps{Class: "justify-end"}) {
|
||||
@card.Footer(card.FooterProps{Class: "justify-between"}) {
|
||||
if trackingID != "" {
|
||||
@button.Button(button.Props{
|
||||
Href: "/account-deletion-status/" + trackingID,
|
||||
Variant: button.VariantGhost,
|
||||
}) {
|
||||
Track status
|
||||
}
|
||||
} else {
|
||||
<span></span>
|
||||
}
|
||||
<form action="/auth/logout" method="POST">
|
||||
@csrf.Token()
|
||||
@button.Button(button.Props{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue