feat: support username admin login

This commit is contained in:
zxyszx
2026-08-02 16:43:54 +08:00
parent 9b59629993
commit 0d55200485
14 changed files with 112 additions and 64 deletions
+14 -15
View File
@@ -94,6 +94,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
@@ -107,14 +108,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
actor := currentUser(r) actor := currentUser(r)
email := normalizeEmail(req.Email) loginName, err := cleanLoginName(req.LoginName, req.Email)
if email == "" || !strings.Contains(email, "@") { if err != nil {
badRequest(w, errors.New("invalid email")) badRequest(w, err)
return return
} }
displayName := strings.TrimSpace(req.DisplayName) displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" { if displayName == "" {
displayName = email displayName = loginName
} }
role := strings.TrimSpace(req.Role) role := strings.TrimSpace(req.Role)
if role == "" { if role == "" {
@@ -154,7 +155,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at) if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?)`, id, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
@@ -521,6 +522,7 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
QuotaMB int `json:"quotaMb"` QuotaMB int `json:"quotaMb"`
Role string `json:"role"` Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"` OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"` UserID string `json:"userId"`
} }
@@ -591,15 +593,12 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return return
} }
} else { } else {
ownerEmail := normalizeEmail(req.OwnerEmail) ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
if ownerEmail == "" { if err != nil {
ownerEmail = address badRequest(w, err)
}
if !strings.Contains(ownerEmail, "@") {
badRequest(w, errors.New("invalid owner email"))
return return
} }
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID) err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerLoginName).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -608,11 +607,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
} }
userID = newID("usr") userID = newID("usr")
ownerDisplayName := displayName ownerDisplayName := displayName
if !strings.EqualFold(ownerEmail, address) { if !strings.EqualFold(ownerLoginName, address) {
ownerDisplayName = ownerEmail ownerDisplayName = ownerLoginName
} }
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now) VALUES(?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
+9 -4
View File
@@ -11,6 +11,7 @@ import (
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"` TurnstileToken string `json:"turnstileToken"`
@@ -49,14 +50,18 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return return
} }
email := normalizeEmail(req.Email) loginName, err := cleanLoginName(req.LoginName, req.Email)
user, passwordHash, err := a.userByEmail(r.Context(), email) if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
if err != nil || user.Disabled { if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "邮箱或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
} }
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "邮箱或密码错误") respondError(w, http.StatusUnauthorized, "账号或密码错误")
return return
} }
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled { if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
+3
View File
@@ -616,6 +616,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
if u == nil { if u == nil {
return nil return nil
} }
if u.LoginName == "" {
u.LoginName = u.Email
}
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role) permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
if err != nil { if err != nil {
return err return err
+1
View File
@@ -4,6 +4,7 @@ import "time"
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
LoginName string `json:"loginName"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
+38
View File
@@ -149,6 +149,44 @@ func normalizeEmail(s string) string {
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1]) return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
} }
func normalizeLoginName(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
if strings.Contains(s, "@") {
return normalizeEmail(s)
}
return normalizeLocalPart(s)
}
func cleanLoginName(value string, fallbacks ...string) (string, error) {
loginName := normalizeLoginName(value)
for _, fallback := range fallbacks {
if loginName != "" {
break
}
loginName = normalizeLoginName(fallback)
}
if loginName == "" {
return "", errors.New("登录名不能为空")
}
if len([]rune(loginName)) > 80 {
return "", errors.New("登录名不能超过 80 个字符")
}
if strings.Contains(loginName, "@") {
parts := strings.SplitN(loginName, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", errors.New("登录名格式无效")
}
return loginName, nil
}
if len([]rune(loginName)) < 2 {
return "", errors.New("登录名至少需要 2 个字符")
}
return loginName, nil
}
func dedupeEmails(items []string) []string { func dedupeEmails(items []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(items)) out := make([]string, 0, len(items))
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "lanqin-email-web", "name": "newszxcn-email-web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -104,7 +104,7 @@ function ProtectedContent() {
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.displayName}</span> <span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span> <span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span>
</div> </div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]"> <Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "普通用户"} {user.role === "admin" ? "管理员" : "普通用户"}
+2 -2
View File
@@ -50,7 +50,7 @@ export type PermissionInfo = { key: PermissionKey; label: string; description: s
export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number } export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string } export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string } export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string } export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
@@ -199,6 +199,6 @@ export type SystemSettings = {
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string } export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type PublicDomain = { id: string; name: string } export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] } export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string } export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
+2 -2
View File
@@ -125,7 +125,7 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"), defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
@@ -134,7 +134,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }), deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"), mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }), deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"), aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
+14 -12
View File
@@ -58,6 +58,7 @@ const projectTag = import.meta.env.VITE_APP_VERSION || ""
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "") const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9 const defaultMailboxLimitOverride = 9
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email
export function AdminPage() { export function AdminPage() {
const me = useMe() const me = useMe()
@@ -221,7 +222,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const canDelete = hasPermission(user, "admin.users.delete") const canDelete = hasPermission(user, "admin.users.delete")
const filteredUsers = users.filter((user) => { const filteredUsers = users.filter((user) => {
const keyword = query.trim().toLowerCase() const keyword = query.trim().toLowerCase()
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) const loginName = accountLoginName(user)
const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
const matchesRole = roleFilter === "all" || user.role === roleFilter const matchesRole = roleFilter === "all" || user.role === roleFilter
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus return matchesKeyword && matchesRole && matchesStatus
@@ -264,9 +266,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium">{user.displayName}</div> <div className="truncate font-medium">{user.displayName}</div>
<div className="truncate text-xs text-muted-foreground">{user.email}</div> <div className="truncate text-xs text-muted-foreground">{accountLoginName(user)}</div>
</div> </div>
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /> <UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<RoleBadge user={user} /> <RoleBadge user={user} />
@@ -286,14 +288,14 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell> <TableCell>
<div className="font-medium">{user.displayName}</div> <div className="font-medium">{user.displayName}</div>
<div className="text-xs text-muted-foreground">{user.email}</div> <div className="text-xs text-muted-foreground">{accountLoginName(user)}</div>
</TableCell> </TableCell>
<TableCell><RoleBadge user={user} /></TableCell> <TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell> <TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell> <TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell> <TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell> <TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell> <TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -1714,7 +1716,7 @@ function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQ
function UserMailboxCell({ user }: { user: AdminUser }) { function UserMailboxCell({ user }: { user: AdminUser }) {
const { toast } = useToast() const { toast } = useToast()
const loginAddress = user.email const loginAddress = accountLoginName(user)
const mailboxes = user.mailboxes || [] const mailboxes = user.mailboxes || []
const [mailboxQuery, setMailboxQuery] = React.useState("") const [mailboxQuery, setMailboxQuery] = React.useState("")
const normalizedQuery = mailboxQuery.trim().toLowerCase() const normalizedQuery = mailboxQuery.trim().toLowerCase()
@@ -1880,7 +1882,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([]) const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
const create = useMutation({ const create = useMutation({
mutationFn: (form: FormData) => api.createUser({ mutationFn: (form: FormData) => api.createUser({
email: String(form.get("email") || ""), loginName: String(form.get("loginName") || ""),
displayName: String(form.get("displayName") || ""), displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""), password: String(form.get("password") || ""),
role, role,
@@ -1897,7 +1899,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> <form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="email" label="登录邮箱" type="email" placeholder="user@example.com" /> <Field name="loginName" label="登录" type="text" autoComplete="off" placeholder="admin" />
<Field name="displayName" label="显示名称" placeholder="账号名称" /> <Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={8} /> <Field name="password" label="初始密码" type="password" minLength={8} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -1951,7 +1953,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> <form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<Field name="email" label="登录邮箱" value={user.email} readOnly /> <Field name="loginName" label="登录" value={accountLoginName(user)} readOnly />
<Field name="displayName" label="显示名称" defaultValue={user.displayName} /> <Field name="displayName" label="显示名称" defaultValue={user.displayName} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> <SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
@@ -1968,7 +1970,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) { function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) }) const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="账号" value={user.email} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
} }
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -1987,8 +1989,8 @@ function CreateDomainDialog() {
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) { function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("") const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按邮箱匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /> : <Field name="ownerEmail" label="归属账号邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
} }
function CreateAliasDialog({ domains }: { domains: Domain[] }) { function CreateAliasDialog({ domains }: { domains: Domain[] }) {
+3 -3
View File
@@ -21,7 +21,7 @@ export function LoginPage() {
const login = useMutation({ const login = useMutation({
mutationFn: (form: FormData) => challengeToken mutationFn: (form: FormData) => challengeToken
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") }) ? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }), : api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }),
onSuccess: async (data) => { onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) { if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken) setChallengeToken(data.challengeToken)
@@ -49,8 +49,8 @@ export function LoginPage() {
{!challengeToken ? ( {!challengeToken ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"></Label> <Label htmlFor="loginName" className="text-sm font-medium"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" /> <Input id="loginName" name="loginName" type="text" autoComplete="username" placeholder="admin 或 admin@newszxcn.com" required className="h-11 text-base" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label> <Label htmlFor="password" className="text-sm font-medium"></Label>
+1 -1
View File
@@ -16,7 +16,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter lanqin-email-web... pnpm install --frozen-lockfile --filter newszxcn-email-web...
COPY apps/web apps/web COPY apps/web apps/web
ARG VITE_APP_VERSION="" ARG VITE_APP_VERSION=""
ARG VITE_RELEASE_URL="" ARG VITE_RELEASE_URL=""
+1 -1
View File
@@ -13,7 +13,7 @@ virtual_alias_maps = sqlite:/etc/postfix/sqlite-aliases.cf
virtual_transport = lmtp:inet:dovecot:24 virtual_transport = lmtp:inet:dovecot:24
virtual_mailbox_base = /var/mail/vhosts virtual_mailbox_base = /var/mail/vhosts
smtpd_banner = $myhostname ESMTP LanQin Email smtpd_banner = $myhostname ESMTP NewSzxcn Email
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
+1 -1
View File
@@ -6,7 +6,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter lanqin-email-web... pnpm install --frozen-lockfile --filter newszxcn-email-web...
COPY apps/web apps/web COPY apps/web apps/web
ARG VITE_APP_VERSION="" ARG VITE_APP_VERSION=""
ARG VITE_RELEASE_URL="" ARG VITE_RELEASE_URL=""