Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.11] - 2026-09-05

### Fixed

- Gave the auth tab switcher a visible keyboard focus indicator (the selected tab previously showed no focus change at all) and raised the `--text-soft` token to an AA-compliant contrast in both themes (light 2.78→4.85:1, dark 4.1→5.76:1).
- Registration now marks the invite code as required and validates it inline, adds `autocomplete` to all auth inputs, announces server auth errors with `role="alert"`, and resolves OAuth links against the API origin so they work in split web/worker deployments.
- Workspace routes now set a per-page document title (e.g. "邮件列表 · WeMail") and the dashboard and inbox pages render a page-level heading, following the existing per-page sr-only h1 convention.
- Link-type extraction chips now name the link by type (登录链接 / 服务链接 / 订阅链接 / 有用链接) instead of a fixed English "LOGIN LINK" label.
- The create-mailbox dialog keeps its primary action enabled and validates on submit with field-level messages, instead of disabling the button until the form is valid.
- The mail list empty state now points users without any mailbox to creating one, and the detail metadata row no longer overflows its panel by a few pixels at 320px width.

## [0.2.10] - 2026-09-05

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wemail/docs",
"version": "0.2.10",
"version": "0.2.11",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wemail/web",
"version": "0.2.10",
"version": "0.2.11",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/app/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ export function AppLayout({
const activeSecondaryRoute =
shell.secondaryNav.find((item) => item.to === location.pathname)?.to ?? shell.secondaryNav[0]?.to ?? "";
const hasAnnouncementBadge = announcementCount > 0;
const activePageLabel =
shell.secondaryNav.find((item) => item.to === activeSecondaryRoute)?.label ?? shell.activePrimaryLabel;

// Screen-reader and browser-history users identify the page by its title;
// without this every route shares the static "WeMail" document title.
useEffect(() => {
document.title = `${activePageLabel} · WeMail`;
}, [activePageLabel]);

useEffect(() => {
const areas = [railScrollRef.current, mainScrollRef.current].filter(Boolean) as Array<HTMLElement>;
Expand Down
34 changes: 27 additions & 7 deletions apps/web/src/features/auth/AuthForms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FormEvent, ReactNode } from "react";
import { CircleAlert, Eye, EyeOff, KeyRound, Mail, Ticket, UserRound } from "lucide-react";
import { useRef, useState } from "react";

import { resolveApiUrl } from "../../shared/api/client";
import { Button, ButtonAnchor } from "../../shared/button";
import { FormField, TextInput } from "../../shared/form";

Expand Down Expand Up @@ -51,6 +52,13 @@ function validatePassword(value: string) {
return null;
}

function validateInviteCode(value: string) {
// Registration requires a valid invite server-side; surface that at the
// field instead of letting an empty submit come back as a banner error.
if (!value.trim()) return getRequiredMessage("邀请码");
return null;
}

function compactFieldErrors(errors: AuthFieldErrors) {
const visibleErrors: AuthFieldErrors = {};

Expand Down Expand Up @@ -152,7 +160,8 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
const nextErrors: AuthFieldErrors = {
registerName: validateName(registerName) ?? undefined,
registerEmail: validateEmail(registerEmail) ?? undefined,
registerPassword: validatePassword(registerPassword) ?? undefined
registerPassword: validatePassword(registerPassword) ?? undefined,
registerInviteCode: validateInviteCode(registerInviteCode) ?? undefined
};
const visibleErrors = compactFieldErrors(nextErrors);

Expand All @@ -168,7 +177,11 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/

return (
<div className="auth-form-shell">
{authError ? <p className="error-banner">{authError}</p> : null}
{authError ? (
<p className="error-banner" role="alert">
{authError}
</p>
) : null}
{mode === "login" ? (
<div aria-labelledby="auth-tab-login" className="auth-form-panel" id="auth-panel-login" role="tabpanel">
<form className="auth-form" noValidate onSubmit={handleLoginSubmit}>
Expand All @@ -181,6 +194,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<TextInput
{...getFieldErrorProps("loginEmail", "login-email")}
className="auth-input-control"
autoComplete="email"
id="login-email"
name="email"
onChange={(event) => {
Expand All @@ -205,6 +219,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<TextInput
{...getFieldErrorProps("loginPassword", "login-password")}
className="auth-input-control"
autoComplete="current-password"
id="login-password"
minLength={8}
name="password"
Expand Down Expand Up @@ -241,7 +256,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<div className="auth-oauth-list" aria-label="快捷登录">
<ButtonAnchor
aria-label="使用 GitHub 登录"
href={`/api/auth/oauth/github/start?next=${encodeURIComponent(oauthNext)}`}
href={resolveApiUrl(`/api/auth/oauth/github/start?next=${encodeURIComponent(oauthNext)}`)}
leadingIcon={
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0.296997C5.37 0.296997 0 5.67 0 12.297C0 17.6 3.438 22.097 8.205 23.682C8.805 23.795 9.025 23.424 9.025 23.105C9.025 22.82 9.015 22.065 9.01 21.065C5.672 21.789 4.968 19.455 4.968 19.455C4.422 18.07 3.633 17.7 3.633 17.7C2.546 16.956 3.717 16.971 3.717 16.971C4.922 17.055 5.555 18.207 5.555 18.207C6.625 20.042 8.364 19.512 9.05 19.205C9.158 18.429 9.467 17.9 9.81 17.6C7.145 17.3 4.344 16.268 4.344 11.67C4.344 10.36 4.809 9.29 5.579 8.45C5.444 8.147 5.039 6.927 5.684 5.274C5.684 5.274 6.689 4.952 8.984 6.504C9.944 6.237 10.964 6.105 11.984 6.099C13.004 6.105 14.024 6.237 14.984 6.504C17.264 4.952 18.269 5.274 18.269 5.274C18.914 6.927 18.509 8.147 18.389 8.45C19.154 9.29 19.619 10.36 19.619 11.67C19.619 16.28 16.814 17.295 14.144 17.59C14.564 17.95 14.954 18.686 14.954 19.81C14.954 21.416 14.939 22.706 14.939 23.096C14.939 23.411 15.149 23.786 15.764 23.666C20.565 22.092 24 17.592 24 12.297C24 5.67 18.627 0.296997 12 0.296997Z"/>
Expand All @@ -253,7 +268,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
</ButtonAnchor>
<ButtonAnchor
aria-label="使用 LinuxDo 登录"
href={`/api/auth/oauth/linuxdo/start?next=${encodeURIComponent(oauthNext)}`}
href={resolveApiUrl(`/api/auth/oauth/linuxdo/start?next=${encodeURIComponent(oauthNext)}`)}
leadingIcon={
<img src="/icons/linuxdo.svg" width="20" height="20" alt="" aria-hidden="true" />
}
Expand All @@ -276,6 +291,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<TextInput
{...getFieldErrorProps("registerName", "register-name")}
className="auth-input-control"
autoComplete="name"
id="register-name"
name="name"
onChange={(event) => {
Expand All @@ -299,6 +315,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<TextInput
{...getFieldErrorProps("registerEmail", "register-email")}
className="auth-input-control"
autoComplete="email"
id="register-email"
name="email"
onChange={(event) => {
Expand All @@ -323,6 +340,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<TextInput
{...getFieldErrorProps("registerPassword", "register-password")}
className="auth-input-control"
autoComplete="new-password"
id="register-password"
minLength={8}
name="password"
Expand Down Expand Up @@ -350,7 +368,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
{renderFieldValidation("registerPassword", "register-password")}
</div>
</FormField>
<FormField htmlFor="register-invite-code" label="邀请码">
<FormField htmlFor="register-invite-code" label="邀请码" required>
<div className="auth-field-stack">
<div className={cx("auth-input-shell form-control-shell", fieldErrors.registerInviteCode && "is-invalid")}>
<span aria-hidden="true" className="auth-input-icon form-control-icon">
Expand All @@ -361,6 +379,8 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
className="auth-input-control"
id="register-invite-code"
name="inviteCode"
placeholder="INVITE-XXXXXXXX"
required
onChange={(event) => {
setRegisterInviteCode(event.target.value);
clearFieldError("registerInviteCode");
Expand All @@ -381,7 +401,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
<div className="auth-oauth-list" aria-label="快捷登录">
<ButtonAnchor
aria-label="使用 GitHub 登录"
href={`/api/auth/oauth/github/start?next=${encodeURIComponent(oauthNext)}`}
href={resolveApiUrl(`/api/auth/oauth/github/start?next=${encodeURIComponent(oauthNext)}`)}
leadingIcon={
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0.296997C5.37 0.296997 0 5.67 0 12.297C0 17.6 3.438 22.097 8.205 23.682C8.805 23.795 9.025 23.424 9.025 23.105C9.025 22.82 9.015 22.065 9.01 21.065C5.672 21.789 4.968 19.455 4.968 19.455C4.422 18.07 3.633 17.7 3.633 17.7C2.546 16.956 3.717 16.971 3.717 16.971C4.922 17.055 5.555 18.207 5.555 18.207C6.625 20.042 8.364 19.512 9.05 19.205C9.158 18.429 9.467 17.9 9.81 17.6C7.145 17.3 4.344 16.268 4.344 11.67C4.344 10.36 4.809 9.29 5.579 8.45C5.444 8.147 5.039 6.927 5.684 5.274C5.684 5.274 6.689 4.952 8.984 6.504C9.944 6.237 10.964 6.105 11.984 6.099C13.004 6.105 14.024 6.237 14.984 6.504C17.264 4.952 18.269 5.274 18.269 5.274C18.914 6.927 18.509 8.147 18.389 8.45C19.154 9.29 19.619 10.36 19.619 11.67C19.619 16.28 16.814 17.295 14.144 17.59C14.564 17.95 14.954 18.686 14.954 19.81C14.954 21.416 14.939 22.706 14.939 23.096C14.939 23.411 15.149 23.786 15.764 23.666C20.565 22.092 24 17.592 24 12.297C24 5.67 18.627 0.296997 12 0.296997Z"/>
Expand All @@ -393,7 +413,7 @@ export function AuthForms({ authError, onRegister, onLogin, mode, oauthNext = "/
</ButtonAnchor>
<ButtonAnchor
aria-label="使用 LinuxDo 登录"
href={`/api/auth/oauth/linuxdo/start?next=${encodeURIComponent(oauthNext)}`}
href={resolveApiUrl(`/api/auth/oauth/linuxdo/start?next=${encodeURIComponent(oauthNext)}`)}
leadingIcon={
<img src="/icons/linuxdo.svg" width="20" height="20" alt="" aria-hidden="true" />
}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/features/inbox/MessageStreamPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type MessageAdvancedFilters = {

type MessageStreamPanelProps = {
filter: MessageFilter;
hasMailboxes?: boolean;
errorMessage?: string | null;
isBatchActionRunning?: boolean;
isLoading?: boolean;
Expand Down Expand Up @@ -67,6 +68,7 @@ const extractionIcons = {

export function MessageStreamPanel({
filter,
hasMailboxes = false,
errorMessage = null,
isBatchActionRunning = false,
isLoading = false,
Expand Down Expand Up @@ -223,7 +225,11 @@ export function MessageStreamPanel({
{messages.length === 0 && !isLoading && !errorMessage ? (
<EmptyState
className="message-empty-card"
description="当前筛选下没有消息,切换筛选或等待新邮件到达。"
description={
hasMailboxes
? "当前筛选下没有消息,切换筛选或等待新邮件到达。"
: "还没有邮箱,先创建一个邮箱开始收信。"
}
icon={<Inbox size={26} strokeWidth={1.8} aria-hidden="true" />}
title="暂无邮件"
/>
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/features/inbox/view-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@ import type { MessageSummary } from "@wemail/shared";

import { formatAttachmentSize, formatReceivedAt, formatSenderName } from "./formatters";

const linkChipLabels: Partial<Record<MessageSummary["extraction"]["type"], string>> = {
auth_link: "登录链接",
service_link: "服务链接",
subscription_link: "订阅链接",
other_link: "有用链接"
};

function toExtractionChip(message: MessageSummary) {
if (message.extraction.type === "auth_code" && message.extraction.value.trim()) {
return { tone: "code", icon: "key", primary: message.extraction.value, secondary: "已提取验证码" } as const;
}

if (["auth_link", "service_link", "subscription_link", "other_link"].includes(message.extraction.type)) {
return { tone: "link", icon: "link", primary: "LOGIN LINK", secondary: message.extraction.label || "已识别链接" } as const;
if (linkChipLabels[message.extraction.type]) {
// The chip names what the link is for; a fixed English label both broke
// the Chinese voice and mislabeled service/subscription links as login.
return { tone: "link", icon: "link", primary: linkChipLabels[message.extraction.type], secondary: message.extraction.label || "已识别链接" } as const;
}

return {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/pages/AuthPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FormEvent, KeyboardEvent } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";

import { AuthForms } from "../features/auth/AuthForms";
Expand Down Expand Up @@ -55,6 +55,11 @@ export function AuthPage({ authError, onRegister, onLogin, onOAuthFinalize, onTo
const [oauthInviteCode, setOAuthInviteCode] = useState("");
const [isOAuthInviteSubmitting, setIsOAuthInviteSubmitting] = useState(false);

// Distinguish the auth tab from every other WeMail tab in the browser.
useEffect(() => {
document.title = `${mode === "register" ? "注册" : "登录"} · WeMail`;
}, [mode]);

function switchMode(nextMode: (typeof AUTH_MODES)[number]) {
if (nextMode === mode) return;
const nextPath = nextMode === "login" ? "/login" : "/register";
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export function DashboardPage({ canViewRoleCard = false }: DashboardPageProps) {

return (
<main className="workspace-grid dashboard-grid">
<h1 className="sr-only">仪表盘</h1>
<section className="dashboard-kpi-grid" aria-label="仪表盘核心指标">
{dashboard.kpis.map((kpi, index) => {
const KpiIcon = KPI_ICONS[index] ?? Inbox;
Expand Down
Loading
Loading