mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98393c8a43 | ||
|
|
9c303e4730 | ||
|
|
d2c8d9278b | ||
|
|
834d3421ea | ||
|
|
1a37692dc5 | ||
|
|
dd109a464a | ||
|
|
3ad30301a9 | ||
|
|
ed48d08503 | ||
|
|
d21f8c6b42 | ||
|
|
e431c1fe5b | ||
|
|
1ffe920d47 | ||
|
|
7398b73f3f |
@@ -1,5 +1,7 @@
|
||||
AUTH_GITHUB_ID = ""
|
||||
AUTH_GITHUB_SECRET = ""
|
||||
AUTH_GOOGLE_ID = ""
|
||||
AUTH_GOOGLE_SECRET = ""
|
||||
AUTH_SECRET = ""
|
||||
|
||||
CLOUDFLARE_API_TOKEN = ""
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -47,6 +47,8 @@ jobs:
|
||||
CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN }}
|
||||
AUTH_GITHUB_ID: ${{ secrets.AUTH_GITHUB_ID }}
|
||||
AUTH_GITHUB_SECRET: ${{ secrets.AUTH_GITHUB_SECRET }}
|
||||
AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }}
|
||||
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
run: pnpm dlx tsx scripts/deploy/index.ts
|
||||
|
||||
|
||||
618
README.en.md
Normal file
618
README.en.md
Normal file
@@ -0,0 +1,618 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="public/icons/icon-192x192.png" alt="MoeMail Logo" width="100" height="100">
|
||||
<h1 align="center">MoeMail</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A cute temporary email service built with NextJS + Cloudflare technology stack 🎉
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#live-demo">Live Demo</a> •
|
||||
<a href="#documentation">Documentation</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#tech-stack">Tech Stack</a> •
|
||||
<a href="#local-run">Local Run</a> •
|
||||
<a href="#deployment">Deployment</a> •
|
||||
<a href="#email-domain-configuration">Email Domain Config</a> •
|
||||
<a href="#permission-system">Permission System</a> •
|
||||
<a href="#system-settings">System Settings</a> •
|
||||
<a href="#sending-emails">Sending Emails</a> •
|
||||
<a href="#webhook-integration">Webhook Integration</a> •
|
||||
<a href="#openapi">OpenAPI</a> •
|
||||
<a href="#environment-variables">Environment Variables</a> •
|
||||
<a href="#github-oauth-app-configuration">Github OAuth Config</a> •
|
||||
<a href="#google-oauth-app-configuration">Google OAuth Config</a> •
|
||||
<a href="#contribution">Contribution</a> •
|
||||
<a href="#license">License</a> •
|
||||
<a href="#community">Community</a> •
|
||||
<a href="#support">Support</a>
|
||||
</p>
|
||||
|
||||
## Live Demo
|
||||
[https://moemail.app](https://moemail.app)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Documentation
|
||||
**Full Documentation**: [https://docs.moemail.app](https://docs.moemail.app)
|
||||
|
||||
The documentation site contains detailed usage guides, API documentation, deployment tutorials, and other complete information.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔒 **Privacy Protection**: Protect your real email address from spam and unnecessary subscriptions
|
||||
- ⚡ **Real-time Receipt**: Automatic polling, receive email notifications instantly
|
||||
- ⏱️ **Flexible Validity**: Supports 1 hour, 24 hours, 3 days, or permanent validity
|
||||
- 🎨 **Theme Switching**: Supports light and dark modes
|
||||
- 📱 **Responsive Design**: Perfectly adapted for desktop and mobile devices
|
||||
- 🔄 **Auto Cleanup**: Automatically cleans up expired mailboxes and emails
|
||||
- 📱 **PWA Support**: Support PWA installation
|
||||
- 💸 **Free Self-hosting**: Built on Cloudflare, capable of free self-hosting without any cost
|
||||
- 🎉 **Cute UI**: Simple and cute UI interface
|
||||
- 📤 **Sending Function**: Support sending emails using temporary addresses, based on Resend service
|
||||
- 🔔 **Webhook Notification**: Support receiving new email notifications via webhook
|
||||
- 🛡️ **Permission System**: Role-based access control system
|
||||
- 🔑 **OpenAPI**: Support accessing OpenAPI via API Key
|
||||
- 🌍 **Multi-language Support**: Supports Chinese and English interfaces, freely switchable
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
|
||||
- **Platform**: [Cloudflare Pages](https://pages.cloudflare.com/)
|
||||
- **Database**: [Cloudflare D1](https://developers.cloudflare.com/d1/) (SQLite)
|
||||
- **Authentication**: [NextAuth](https://authjs.dev/getting-started/installation?framework=Next.js) with GitHub/Google Login
|
||||
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
|
||||
- **UI Components**: Custom components based on [Radix UI](https://www.radix-ui.com/)
|
||||
- **Email Handling**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
|
||||
- **Type Safety**: [TypeScript](https://www.typescriptlang.org/)
|
||||
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
- **Internationalization**: [next-intl](https://next-intl-docs.vercel.app/)
|
||||
|
||||
## Local Run
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Pnpm
|
||||
- Wrangler CLI
|
||||
- Cloudflare Account
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/beilunyang/moemail.git
|
||||
cd moemail
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Setup Wrangler:
|
||||
```bash
|
||||
cp wrangler.example.json wrangler.json
|
||||
cp wrangler.email.example.json wrangler.email.json
|
||||
cp wrangler.cleanup.example.json wrangler.cleanup.json
|
||||
```
|
||||
Set Cloudflare D1 database name and database ID.
|
||||
|
||||
4. Setup Environment Variables:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
Set `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`, `AUTH_SECRET`.
|
||||
|
||||
5. Create local database schema:
|
||||
```bash
|
||||
pnpm db:migrate-local
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
1. Start development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. Test Email Worker:
|
||||
Currently cannot run and test locally, please use Wrangler to deploy the email worker and test.
|
||||
```bash
|
||||
pnpm deploy:email
|
||||
```
|
||||
|
||||
3. Test Cleanup Worker:
|
||||
```bash
|
||||
pnpm dev:cleanup
|
||||
pnpm test:cleanup
|
||||
```
|
||||
|
||||
4. Generate Mock Data (Mailboxes and Messages):
|
||||
```bash
|
||||
pnpm generate-test-data
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Video Tutorial
|
||||
https://www.youtube.com/watch?v=Vcw3nqsq2-E
|
||||
|
||||
### Local Wrangler Deployment
|
||||
1. Create .env file
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. Set [Environment Variables](#environment-variables) in the .env file.
|
||||
|
||||
3. Run deployment script
|
||||
```bash
|
||||
pnpm dlx tsx ./scripts/deploy/index.ts
|
||||
```
|
||||
|
||||
### Github Actions Deployment
|
||||
|
||||
This project supports automated deployment using GitHub Actions. It supports the following triggers:
|
||||
|
||||
1. **Auto Trigger**: Automatically triggers deployment flow when a new tag is pushed.
|
||||
2. **Manual Trigger**: Manually trigger in the GitHub Actions page.
|
||||
|
||||
#### Deployment Steps
|
||||
|
||||
1. Add the following Secrets in GitHub repository settings:
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret, used to encrypt session, please set a random string
|
||||
- `CUSTOM_DOMAIN`: Custom domain for the website (Optional, if empty, uses Cloudflare Pages default domain)
|
||||
- `PROJECT_NAME`: Pages project name (Optional, if empty, defaults to moemail)
|
||||
- `DATABASE_NAME`: D1 database name (Optional, if empty, defaults to moemail-db)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace name, used for site settings (Optional, if empty, defaults to moemail-kv)
|
||||
|
||||
2. Choose trigger method:
|
||||
|
||||
**Method 1: Push Tag Trigger**
|
||||
```bash
|
||||
# Create a new tag
|
||||
git tag v1.0.0
|
||||
|
||||
# Push tag to remote repository
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
**Method 2: Manual Trigger**
|
||||
- Go to the Actions page of the repository
|
||||
- Select "Deploy" workflow
|
||||
- Click "Run workflow"
|
||||
|
||||
3. Deployment progress can be viewed in the Actions tab of the repository.
|
||||
|
||||
#### Notes
|
||||
- Ensure all Secrets are set correctly.
|
||||
- When using tag trigger, the tag must start with `v` (e.g., v1.0.0).
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||
|
||||
|
||||
## Email Domain Configuration
|
||||
|
||||
In the MoeMail User Profile page, you can configure the site's email domains. Supports multiple domain configurations, separated by commas.
|
||||

|
||||
|
||||
### Cloudflare Email Routing Configuration
|
||||
|
||||
To make email domains effective, you also need to configure email routing in the Cloudflare console to forward received emails to the Email Worker.
|
||||
|
||||
1. Login to [Cloudflare Console](https://dash.cloudflare.com/)
|
||||
2. Select your domain
|
||||
3. Click "Email" -> "Email Routing" in the left menu
|
||||
4. If it shows "Email Routing is currently disabled", please click "Enable Email Routing"
|
||||

|
||||
5. After clicking, it will prompt you to add Email Routing DNS records, click "Add records and enable"
|
||||

|
||||
6. Configure Routing Rules:
|
||||
- Catch-all address: Enable "Catch-all"
|
||||
- Edit Catch-all address
|
||||
- Action: Select "Send to Worker"
|
||||
- Destination: Select the "email-receiver-worker" you just deployed
|
||||
- Save
|
||||

|
||||
|
||||
### Notes
|
||||
- Ensure domain DNS is hosted on Cloudflare.
|
||||
- Email Worker must be successfully deployed.
|
||||
- If Catch-All status is unavailable (stuck loading), please click `Destination addresses` next to `Routing rules`, and bind an email address there.
|
||||
|
||||
## Permission System
|
||||
|
||||
The project uses a Role-Based Access Control (RBAC) system.
|
||||
|
||||
### Role Configuration
|
||||
|
||||
New user default roles are configured by the Emperor in the site settings in the User Profile:
|
||||
- Duke: New users get temporary email, Webhook config permissions, and API Key management permissions.
|
||||
- Knight: New users get temporary email and Webhook config permissions.
|
||||
- Civilian: New users have no permissions, need to wait for Emperor to promote to Knight or Duke.
|
||||
|
||||
### Role Levels
|
||||
|
||||
The system includes four role levels:
|
||||
|
||||
1. **Emperor**
|
||||
- Website Owner
|
||||
- Has all permissions
|
||||
- Only one Emperor per site
|
||||
|
||||
2. **Duke**
|
||||
- Super User
|
||||
- Can use temporary email features
|
||||
- Can configure Webhook
|
||||
- Can create API Key to call OpenAPI
|
||||
- Can be demoted to Knight or Civilian by Emperor
|
||||
|
||||
3. **Knight**
|
||||
- Advanced User
|
||||
- Can use temporary email features
|
||||
- Can configure Webhook
|
||||
- Can be demoted to Civilian or promoted to Duke by Emperor
|
||||
|
||||
4. **Civilian**
|
||||
- Regular User
|
||||
- No permissions
|
||||
- Can be promoted to Knight or Duke by Emperor
|
||||
|
||||
### Role Upgrade
|
||||
|
||||
1. **Become Emperor**
|
||||
- The first user to visit `/api/roles/init-emperor` interface will become the Emperor (Website Owner).
|
||||
- Once an Emperor exists, no other user can be promoted to Emperor.
|
||||
|
||||
2. **Role Changes**
|
||||
- The Emperor can set other users as Duke, Knight, or Civilian in the User Profile page.
|
||||
|
||||
### Permission Details
|
||||
|
||||
- **Email Management**: Create and manage temporary emails
|
||||
- **Webhook Management**: Configure Webhooks for email notifications
|
||||
- **API Key Management**: Create and manage API access keys
|
||||
- **User Management**: Promote/Demote user roles
|
||||
- **System Settings**: Manage global system settings
|
||||
|
||||
## System Settings
|
||||
|
||||
System settings are stored in Cloudflare KV, including:
|
||||
|
||||
- `DEFAULT_ROLE`: Default role for new users, values: `CIVILIAN`, `KNIGHT`, `DUKE`
|
||||
- `EMAIL_DOMAINS`: Supported email domains, comma-separated
|
||||
- `ADMIN_CONTACT`: Administrator contact info
|
||||
- `MAX_EMAILS`: Maximum number of emails per user
|
||||
|
||||
**Emperor** role can configure these in the User Profile page.
|
||||
|
||||
## Sending Emails
|
||||
|
||||
MoeMail supports sending emails using temporary addresses, based on [Resend](https://resend.com/) service.
|
||||
|
||||
### Features
|
||||
|
||||
- 📨 **Send from Temp Email**: Use created temporary emails as sender
|
||||
- 🎯 **Role Limits**: Different roles have different daily sending limits
|
||||
- 💌 **HTML Support**: Supports rich text email format
|
||||
|
||||
### Role Sending Limits
|
||||
|
||||
| Role | Daily Limit | Description |
|
||||
|------|-------------|-------------|
|
||||
| Emperor | Unlimited | Admin has no limits |
|
||||
| Duke | 5/day | Default 5 emails per day |
|
||||
| Knight | 2/day | Default 2 emails per day |
|
||||
| Civilian | Forbidden | No sending permission |
|
||||
|
||||
> 💡 **Tip**: The Emperor can customize the daily limits for Dukes and Knights in the Mail Service Configuration.
|
||||
|
||||
### Configure Sending Service
|
||||
|
||||
1. **Get Resend API Key**
|
||||
- Register at [Resend](https://resend.com/)
|
||||
- Create API Key in console
|
||||
- Copy API Key for later use
|
||||
|
||||
2. **Configure Service**
|
||||
- Login as Emperor
|
||||
- Go to User Profile
|
||||
- In "Resend Service Configuration":
|
||||
- Enable Sending Service switch
|
||||
- Enter Resend API Key
|
||||
- Set daily limits for Duke and Knight (Optional)
|
||||
- Save configuration
|
||||
|
||||
3. **Verify Configuration**
|
||||
- After saving, authorized users will see a "Send Email" button in the email list
|
||||
- Click to open dialog and test
|
||||
|
||||
### How to Send
|
||||
|
||||
1. **Create Temp Email**
|
||||
- Create a new temporary email in Mailbox page
|
||||
|
||||
2. **Send Email**
|
||||
- Find the email in the list
|
||||
- Click "Send Email" button next to it
|
||||
- Fill in:
|
||||
- Recipient address
|
||||
- Subject
|
||||
- Content (supports HTML)
|
||||
- Click "Send"
|
||||
|
||||
3. **View History**
|
||||
- Sent emails are saved in the message list of the corresponding mailbox
|
||||
- View all sent/received emails in mailbox detail page
|
||||
|
||||
### Notes
|
||||
|
||||
- 📋 **Resend Limits**: Please note Resend's sending limits and pricing
|
||||
- 🔐 **Domain Verification**: Using custom domains requires verification in Resend
|
||||
- 🚫 **Anti-Spam**: Please follow email sending standards, avoid spamming
|
||||
- 📊 **Quota Monitoring**: System counts daily usage, stops sending when limit reached
|
||||
- 🔄 **Quota Reset**: Daily quota resets at 00:00
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
When a new email is received, the system sends a POST request to the configured and enabled Webhook URL.
|
||||
|
||||
### Request Header
|
||||
```http
|
||||
Content-Type: application/json
|
||||
X-Webhook-Event: new_message
|
||||
```
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"emailId": "email-uuid",
|
||||
"messageId": "message-uuid",
|
||||
"fromAddress": "sender@example.com",
|
||||
"subject": "Email Subject",
|
||||
"content": "Email Text Content",
|
||||
"html": "Email HTML Content",
|
||||
"receivedAt": "2024-01-01T12:00:00.000Z",
|
||||
"toAddress": "your-email@moemail.app"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
1. Click avatar to enter User Profile
|
||||
2. Enable Webhook
|
||||
3. Set notification URL
|
||||
4. Click Test button
|
||||
5. Save to receive notifications
|
||||
|
||||
### Testing
|
||||
|
||||
The project provides a simple test server:
|
||||
|
||||
```bash
|
||||
pnpm webhook-test-server
|
||||
```
|
||||
|
||||
The test server listens on port 3001 (http://localhost:3001) and prints received Webhook details.
|
||||
|
||||
For external testing, use Cloudflare Tunnel:
|
||||
```bash
|
||||
pnpx cloudflared tunnel --url http://localhost:3001
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Webhook must respond within 10 seconds
|
||||
- Non-2xx response triggers retry
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The project provides OpenAPI interfaces, accessible via API Key. API Keys can be created in User Profile (Requires Duke or Emperor role).
|
||||
|
||||
### Using API Key
|
||||
|
||||
Add API Key to request header:
|
||||
```http
|
||||
X-API-Key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get System Config
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"defaultRole": "CIVILIAN",
|
||||
"emailDomains": "moemail.app,example.com",
|
||||
"adminContact": "admin@example.com",
|
||||
"maxEmails": "10"
|
||||
}
|
||||
```
|
||||
|
||||
#### Generate Temp Email
|
||||
```http
|
||||
POST /api/emails/generate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "test",
|
||||
"expiryTime": 3600000,
|
||||
"domain": "moemail.app"
|
||||
}
|
||||
```
|
||||
Params:
|
||||
- `name`: Prefix (optional)
|
||||
- `expiryTime`: Validity in ms. 3600000(1h), 86400000(24h), 604800000(7d), 0(Permanent)
|
||||
- `domain`: From config
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"email": "test@moemail.app"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Email List
|
||||
```http
|
||||
GET /api/emails?cursor=xxx
|
||||
```
|
||||
|
||||
#### Get Messages for Email
|
||||
```http
|
||||
GET /api/emails/{emailId}?cursor=xxx
|
||||
```
|
||||
|
||||
#### Delete Email
|
||||
```http
|
||||
DELETE /api/emails/{emailId}
|
||||
```
|
||||
|
||||
#### Get Single Message
|
||||
```http
|
||||
GET /api/emails/{emailId}/{messageId}
|
||||
```
|
||||
|
||||
#### Create Email Share Link
|
||||
```http
|
||||
POST /api/emails/{emailId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Email Share Links
|
||||
```http
|
||||
GET /api/emails/{emailId}/share
|
||||
```
|
||||
|
||||
#### Delete Email Share Link
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/share/{shareId}
|
||||
```
|
||||
|
||||
#### Create Message Share Link
|
||||
```http
|
||||
POST /api/emails/{emailId}/messages/{messageId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Message Share Links
|
||||
```http
|
||||
GET /api/emails/{emailId}/messages/{messageId}/share
|
||||
```
|
||||
|
||||
#### Delete Message Share Link
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Authentication
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_GOOGLE_ID`: Google OAuth App ID
|
||||
- `AUTH_GOOGLE_SECRET`: Google OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret
|
||||
|
||||
### Cloudflare
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
|
||||
- `DATABASE_NAME`: D1 Database Name
|
||||
- `DATABASE_ID`: D1 Database ID (Optional, auto-fetched if empty)
|
||||
- `KV_NAMESPACE_NAME`: KV Name
|
||||
- `KV_NAMESPACE_ID`: KV ID (Optional, auto-fetched if empty)
|
||||
- `CUSTOM_DOMAIN`: Custom domain
|
||||
- `PROJECT_NAME`: Pages Project Name
|
||||
|
||||
## Github OAuth App Configuration
|
||||
|
||||
1. Login [Github Developer](https://github.com/settings/developers) create new OAuth App
|
||||
2. Generate `Client ID` and `Client Secret`
|
||||
3. Configure:
|
||||
- `Application name`: `<your-app-name>`
|
||||
- `Homepage URL`: `https://<your-domain>`
|
||||
- `Authorization callback URL`: `https://<your-domain>/api/auth/callback/github`
|
||||
|
||||
## Google OAuth App Configuration
|
||||
|
||||
1. Visit [Google Cloud Console](https://console.cloud.google.com/) create project
|
||||
2. Configure OAuth consent screen
|
||||
3. Create OAuth Client ID
|
||||
- Type: Web application
|
||||
- Authorized Javascript origins: `https://<your-domain>`
|
||||
- Authorized redirect URIs: `https://<your-domain>/api/auth/callback/google`
|
||||
4. Get `Client ID` and `Client Secret`
|
||||
5. Configure env vars `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET`
|
||||
|
||||
## Contribution
|
||||
|
||||
Welcome to submit Pull Requests or Issues to help improve this project.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Community
|
||||
<table>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADAcQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://pic.otaku.ren/20250309/AQADCMQxGxQjaVZ-.jpg" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="max-width: 360px">
|
||||
<td>
|
||||
Follow official account for more project updates, AI, Blockchain, and Indie Dev news.
|
||||
</td>
|
||||
<td>
|
||||
Add WeChat, remark "MoeMail" to join the WeChat community group.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project, please give it a Star ⭐️
|
||||
Or sponsor it
|
||||
<br />
|
||||
<br />
|
||||
<img src="https://pic.otaku.ren/20240212/AQADPrgxGwoIWFZ-.jpg" style="width: 400px;"/>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#beilunyang/moemail&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
39
README.md
39
README.md
@@ -7,8 +7,14 @@
|
||||
一个基于 NextJS + Cloudflare 技术栈构建的可爱临时邮箱服务🎉
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">简体中文</a> |
|
||||
<a href="./README.en.md">English</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#在线演示">在线演示</a> •
|
||||
<a href="#文档">文档</a> •
|
||||
<a href="#特性">特性</a> •
|
||||
<a href="#技术栈">技术栈</a> •
|
||||
<a href="#本地运行">本地运行</a> •
|
||||
@@ -21,6 +27,7 @@
|
||||
<a href="#OpenAPI">OpenAPI</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
||||
<a href="#Google OAuth App 配置">Google OAuth App 配置</a> •
|
||||
<a href="#贡献">贡献</a> •
|
||||
<a href="#许可证">许可证</a> •
|
||||
<a href="#交流群">交流群</a> •
|
||||
@@ -37,6 +44,11 @@
|
||||
|
||||

|
||||
|
||||
## 文档
|
||||
**完整文档**: [https://docs.moemail.app](https://docs.moemail.app)
|
||||
|
||||
文档站点包含详细的使用指南、API 文档、部署教程等完整信息。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🔒 **隐私保护**:保护您的真实邮箱地址,远离垃圾邮件和不必要的订阅
|
||||
@@ -776,6 +788,8 @@ console.log('分享链接:', `https://your-domain.com/shared/message/${data.toke
|
||||
### 认证相关
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_GOOGLE_ID`: Google OAuth App ID
|
||||
- `AUTH_GOOGLE_SECRET`: Google OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||
|
||||
### Cloudflare 配置
|
||||
@@ -790,16 +804,29 @@ console.log('分享链接:', `https://your-domain.com/shared/message/${data.toke
|
||||
|
||||
## Github OAuth App 配置
|
||||
|
||||
- 登录 [Github Developer](https://github.com/settings/developers) 创建一个新的 OAuth App
|
||||
- 生成一个新的 `Client ID` 和 `Client Secret`
|
||||
- 设置 `Application name` 为 `<your-app-name>`
|
||||
- 设置 `Homepage URL` 为 `https://<your-domain>`
|
||||
- 设置 `Authorization callback URL` 为 `https://<your-domain>/api/auth/callback/github`
|
||||
1. 登录 [Github Developer](https://github.com/settings/developers) 创建一个新的 OAuth App
|
||||
2. 生成一个新的 `Client ID` 和 `Client Secret`
|
||||
3. 配置参数:
|
||||
- `Application name`: `<your-app-name>`
|
||||
- `Homepage URL`: `https://<your-domain>`
|
||||
- `Authorization callback URL`: `https://<your-domain>/api/auth/callback/github`
|
||||
|
||||
## Google OAuth App 配置
|
||||
|
||||
1. 访问 [Google Cloud Console](https://console.cloud.google.com/) 创建项目
|
||||
2. 配置 OAuth 同意屏幕
|
||||
3. 创建 OAuth 客户端 ID
|
||||
- 应用类型:Web 应用
|
||||
- 已获授权的 Javascript 来源:`https://<your-domain>`
|
||||
- 已获授权的重定向 URI:`https://<your-domain>/api/auth/callback/google`
|
||||
4. 获取 `Client ID` 和 `Client Secret`
|
||||
5. 配置环境变量 `AUTH_GOOGLE_ID` 和 `AUTH_GOOGLE_SECRET`
|
||||
|
||||
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Pull Request 或者 Issue来帮助改进这个项目
|
||||
欢迎提交 Pull Request 或者 Issue 来帮助改进这个项目
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function generateMetadata({
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: locale === "zh-CN" ? "zh_CN" : locale,
|
||||
locale: locale === "zh-CN" ? "zh_CN" : locale === "zh-TW" ? "zh_TW" : locale,
|
||||
url: `${baseUrl}/${locale}`,
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
@@ -145,4 +145,3 @@ export default async function LocaleLayout({
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LoginForm } from "@/components/auth/login-form"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
import { getTurnstileConfig } from "@/lib/turnstile"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
@@ -18,10 +19,11 @@ export default async function LoginPage({
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
const turnstile = await getTurnstileConfig()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<LoginForm />
|
||||
<LoginForm turnstile={{ enabled: turnstile.enabled, siteKey: turnstile.siteKey }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,6 @@ export function SharedEmailPageClient({
|
||||
return tShared("sharedMailbox")
|
||||
}
|
||||
})()}
|
||||
showCta={true}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getSharedEmail, getSharedEmailMessages } from "@/lib/shared-data"
|
||||
import { SharedErrorPage } from "@/components/emails/shared-error-page"
|
||||
import { SharedEmailPageClient } from "./page-client"
|
||||
@@ -12,7 +11,6 @@ interface PageProps {
|
||||
|
||||
export default async function SharedEmailPage({ params }: PageProps) {
|
||||
const { token } = await params
|
||||
const tShared = await getTranslations("emails.shared")
|
||||
|
||||
// 服务端获取数据
|
||||
const email = await getSharedEmail(token)
|
||||
@@ -20,11 +18,11 @@ export default async function SharedEmailPage({ params }: PageProps) {
|
||||
if (!email) {
|
||||
return (
|
||||
<SharedErrorPage
|
||||
title={tShared("emailNotFound")}
|
||||
subtitle={tShared("linkExpired")}
|
||||
error={tShared("linkInvalid")}
|
||||
description={tShared("linkInvalidDescription")}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
titleKey="emailNotFound"
|
||||
subtitleKey="linkExpired"
|
||||
errorKey="linkInvalid"
|
||||
descriptionKey="linkInvalidDescription"
|
||||
ctaTextKey="createOwnEmail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export function SharedMessagePageClient({ message }: SharedMessagePageClientProp
|
||||
: message.emailExpiresAt
|
||||
? `${tShared("expiresAt")}: ${new Date(message.emailExpiresAt).toLocaleString()}`
|
||||
: tShared("sharedMessage")}
|
||||
showCta={true}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getSharedMessage } from "@/lib/shared-data"
|
||||
import { SharedErrorPage } from "@/components/emails/shared-error-page"
|
||||
import { SharedMessagePageClient } from "./page-client"
|
||||
@@ -12,7 +11,6 @@ interface PageProps {
|
||||
|
||||
export default async function SharedMessagePage({ params }: PageProps) {
|
||||
const { token } = await params
|
||||
const tShared = await getTranslations("emails.shared")
|
||||
|
||||
// 服务端获取数据
|
||||
const message = await getSharedMessage(token)
|
||||
@@ -20,11 +18,11 @@ export default async function SharedMessagePage({ params }: PageProps) {
|
||||
if (!message) {
|
||||
return (
|
||||
<SharedErrorPage
|
||||
title={tShared("messageNotFound")}
|
||||
subtitle={tShared("linkExpired")}
|
||||
error={tShared("linkInvalid")}
|
||||
description={tShared("linkInvalidDescription")}
|
||||
ctaText={tShared("createOwnEmail")}
|
||||
titleKey="messageNotFound"
|
||||
subtitleKey="linkExpired"
|
||||
errorKey="linkInvalid"
|
||||
descriptionKey="linkInvalidDescription"
|
||||
ctaTextKey="createOwnEmail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { register } from "@/lib/auth"
|
||||
import { authSchema, AuthSchema } from "@/lib/validation"
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
@@ -17,7 +18,16 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const { username, password } = json
|
||||
const { username, password, turnstileToken } = json
|
||||
|
||||
const verification = await verifyTurnstileToken(turnstileToken)
|
||||
if (!verification.success) {
|
||||
const message = verification.reason === "missing-token"
|
||||
? "请先完成安全验证"
|
||||
: "安全验证未通过"
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await register(username, password)
|
||||
|
||||
return NextResponse.json({ user })
|
||||
@@ -27,4 +37,4 @@ export async function POST(request: Request) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,36 @@ export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
const env = getRequestContext().env
|
||||
const [defaultRole, emailDomains, adminContact, maxEmails] = await Promise.all([
|
||||
const canManageConfig = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
const [
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileSecretKey
|
||||
] = await Promise.all([
|
||||
env.SITE_CONFIG.get("DEFAULT_ROLE"),
|
||||
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
|
||||
env.SITE_CONFIG.get("ADMIN_CONTACT"),
|
||||
env.SITE_CONFIG.get("MAX_EMAILS")
|
||||
env.SITE_CONFIG.get("MAX_EMAILS"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_ENABLED"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SITE_KEY"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SECRET_KEY")
|
||||
])
|
||||
|
||||
return Response.json({
|
||||
defaultRole: defaultRole || ROLES.CIVILIAN,
|
||||
emailDomains: emailDomains || "moemail.app",
|
||||
adminContact: adminContact || "",
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
|
||||
turnstile: canManageConfig ? {
|
||||
enabled: turnstileEnabled === "true",
|
||||
siteKey: turnstileSiteKey || "",
|
||||
secretKey: turnstileSecretKey || "",
|
||||
} : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,24 +49,48 @@ export async function POST(request: Request) {
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const { defaultRole, emailDomains, adminContact, maxEmails } = await request.json() as {
|
||||
const {
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails,
|
||||
turnstile
|
||||
} = await request.json() as {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
maxEmails: string,
|
||||
turnstile?: {
|
||||
enabled: boolean,
|
||||
siteKey: string,
|
||||
secretKey: string
|
||||
}
|
||||
}
|
||||
|
||||
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
|
||||
return Response.json({ error: "无效的角色" }, { status: 400 })
|
||||
}
|
||||
|
||||
const turnstileConfig = turnstile ?? {
|
||||
enabled: false,
|
||||
siteKey: "",
|
||||
secretKey: ""
|
||||
}
|
||||
|
||||
if (turnstileConfig.enabled && (!turnstileConfig.siteKey || !turnstileConfig.secretKey)) {
|
||||
return Response.json({ error: "Turnstile 启用时需要提供 Site Key 和 Secret Key" }, { status: 400 })
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
|
||||
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
|
||||
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
|
||||
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails)
|
||||
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails),
|
||||
env.SITE_CONFIG.put("TURNSTILE_ENABLED", turnstileConfig.enabled.toString()),
|
||||
env.SITE_CONFIG.put("TURNSTILE_SITE_KEY", turnstileConfig.siteKey),
|
||||
env.SITE_CONFIG.put("TURNSTILE_SECRET_KEY", turnstileConfig.secretKey)
|
||||
])
|
||||
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
@@ -21,6 +21,16 @@ import {
|
||||
} from "@/components/ui/tabs"
|
||||
import { Github, Loader2, KeyRound, User2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Turnstile } from "@/components/auth/turnstile"
|
||||
|
||||
interface TurnstileConfigProps {
|
||||
enabled: boolean
|
||||
siteKey: string
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
turnstile?: TurnstileConfigProps
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string
|
||||
@@ -28,15 +38,50 @@ interface FormErrors {
|
||||
confirmPassword?: string
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
export function LoginForm({ turnstile }: LoginFormProps) {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const [turnstileResetCounter, setTurnstileResetCounter] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<"login" | "register">("login")
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations("auth.loginForm")
|
||||
|
||||
const turnstileSiteKey = turnstile?.siteKey ?? ""
|
||||
const turnstileEnabled = Boolean(turnstile?.enabled && turnstileSiteKey)
|
||||
|
||||
const resetTurnstile = useCallback(() => {
|
||||
setTurnstileToken("")
|
||||
setTurnstileResetCounter((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const ensureTurnstileSolved = () => {
|
||||
if (!turnstileEnabled) return true
|
||||
if (turnstileToken) return true
|
||||
|
||||
toast({
|
||||
title: t("toast.turnstileRequired"),
|
||||
description: t("toast.turnstileRequiredDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const clearForm = () => {
|
||||
setUsername("")
|
||||
setPassword("")
|
||||
setConfirmPassword("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as "login" | "register")
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const validateLoginForm = () => {
|
||||
const newErrors: FormErrors = {}
|
||||
if (!username) newErrors.username = t("errors.usernameRequired")
|
||||
@@ -61,22 +106,25 @@ export function LoginForm() {
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateLoginForm()) return
|
||||
if (!ensureTurnstileSolved()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
turnstileToken,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: t("toast.loginFailed"),
|
||||
description: t("toast.loginFailedDesc"),
|
||||
description: result.error,
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,18 +136,20 @@ export function LoginForm() {
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!validateRegisterForm()) return
|
||||
if (!ensureTurnstileSolved()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ username, password, turnstileToken }),
|
||||
})
|
||||
|
||||
const data = await response.json() as { error?: string }
|
||||
@@ -111,6 +161,7 @@ export function LoginForm() {
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,16 +169,18 @@ export function LoginForm() {
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
turnstileToken,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: t("toast.loginFailed"),
|
||||
description: t("toast.autoLoginFailed"),
|
||||
description: result.error || t("toast.autoLoginFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,6 +192,7 @@ export function LoginForm() {
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +200,8 @@ export function LoginForm() {
|
||||
signIn("github", { callbackUrl: "/" })
|
||||
}
|
||||
|
||||
const clearForm = () => {
|
||||
setUsername("")
|
||||
setPassword("")
|
||||
setConfirmPassword("")
|
||||
setErrors({})
|
||||
const handleGoogleLogin = () => {
|
||||
signIn("google", { callbackUrl: "/" })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -164,7 +215,7 @@ export function LoginForm() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6">
|
||||
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={handleTabChange}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
|
||||
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
|
||||
@@ -250,6 +301,32 @@ export function LoginForm() {
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
{t("actions.githubLogin")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{t("actions.googleLogin")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="register" className="space-y-4 mt-0">
|
||||
@@ -340,7 +417,17 @@ export function LoginForm() {
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{turnstileEnabled && turnstileSiteKey && (
|
||||
<div className={cn("space-y-2", activeTab === "login" ? "mt-4" : "")}>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
onExpire={resetTurnstile}
|
||||
resetSignal={turnstileResetCounter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
104
app/components/auth/turnstile.tsx
Normal file
104
app/components/auth/turnstile.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TurnstileProps {
|
||||
siteKey: string
|
||||
onVerify: (token: string) => void
|
||||
onExpire?: () => void
|
||||
resetSignal?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Turnstile({
|
||||
siteKey,
|
||||
onVerify,
|
||||
onExpire,
|
||||
resetSignal,
|
||||
className,
|
||||
}: TurnstileProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widgetIdRef = useRef<string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) return
|
||||
|
||||
const renderWidget = () => {
|
||||
if (!containerRef.current || !window.turnstile) return
|
||||
|
||||
if (widgetIdRef.current) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme: "auto",
|
||||
callback: (token: string) => onVerify(token),
|
||||
"error-callback": () => onVerify(""),
|
||||
"expired-callback": () => {
|
||||
onVerify("")
|
||||
onExpire?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>('script[data-turnstile="true"]')
|
||||
|
||||
if (window.turnstile) {
|
||||
renderWidget()
|
||||
} else if (existingScript) {
|
||||
const handleExistingScriptLoad = () => renderWidget()
|
||||
|
||||
if (existingScript.dataset.loaded === "true") {
|
||||
renderWidget()
|
||||
} else {
|
||||
existingScript.addEventListener("load", handleExistingScriptLoad)
|
||||
}
|
||||
|
||||
return () => {
|
||||
existingScript.removeEventListener("load", handleExistingScriptLoad)
|
||||
}
|
||||
} else {
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.turnstile = "true"
|
||||
const handleScriptLoad = () => {
|
||||
script.dataset.loaded = "true"
|
||||
renderWidget()
|
||||
}
|
||||
script.addEventListener("load", handleScriptLoad)
|
||||
document.head.appendChild(script)
|
||||
|
||||
return () => {
|
||||
script.removeEventListener("load", handleScriptLoad)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [siteKey, onExpire, onVerify])
|
||||
|
||||
useEffect(() => {
|
||||
if (resetSignal === undefined) return
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
}
|
||||
onVerify("")
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetSignal])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex justify-center", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { BrandHeader } from "@/components/ui/brand-header"
|
||||
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
|
||||
|
||||
interface SharedErrorPageProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
error: string
|
||||
description: string
|
||||
ctaText: string
|
||||
titleKey: string
|
||||
subtitleKey: string
|
||||
errorKey: string
|
||||
descriptionKey: string
|
||||
ctaTextKey: string
|
||||
}
|
||||
|
||||
export function SharedErrorPage({ title, subtitle, error, description, ctaText }: SharedErrorPageProps) {
|
||||
export function SharedErrorPage({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
errorKey,
|
||||
descriptionKey,
|
||||
ctaTextKey,
|
||||
}: SharedErrorPageProps) {
|
||||
const tShared = useTranslations("emails.shared")
|
||||
|
||||
const resolvedTitle = tShared(titleKey)
|
||||
const resolvedSubtitle = tShared(subtitleKey)
|
||||
const resolvedError = tShared(errorKey)
|
||||
const resolvedDescription = tShared(descriptionKey)
|
||||
const resolvedCtaText = tShared(ctaTextKey)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col justify-center items-center">
|
||||
<div className="container mx-auto p-4 max-w-4xl">
|
||||
<BrandHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
showCta={true}
|
||||
ctaText={ctaText}
|
||||
title={resolvedTitle}
|
||||
subtitle={resolvedSubtitle}
|
||||
ctaText={resolvedCtaText}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="text-center mt-6">
|
||||
<Card className="max-w-md mx-auto p-8 text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-destructive" />
|
||||
<h2 className="text-2xl font-bold">{error}</h2>
|
||||
<h2 className="text-2xl font-bold">{resolvedError}</h2>
|
||||
<p className="text-gray-500">
|
||||
{description}
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -37,4 +51,4 @@ export function SharedErrorPage({ title, subtitle, error, description, ctaText }
|
||||
<FloatingLanguageSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
|
||||
import { LOCALE_LABELS } from "@/i18n/config"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
import {
|
||||
@@ -13,38 +12,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function FloatingLanguageSwitcher() {
|
||||
const t = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as any)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
const newPath = segments.join("/")
|
||||
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const getLanguageName = (loc: string) => {
|
||||
switch (loc) {
|
||||
case "en":
|
||||
return "English"
|
||||
case "zh-CN":
|
||||
return "简体中文"
|
||||
default:
|
||||
return loc
|
||||
}
|
||||
}
|
||||
const { locale, locales, switchLocale } = useLocaleSwitcher()
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
@@ -54,19 +22,19 @@ export function FloatingLanguageSwitcher() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
<span className="sr-only">{t("lang.switch")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" className="mb-2">
|
||||
{i18n.locales.map((loc) => (
|
||||
{locales.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={locale === loc ? "bg-accent" : ""}
|
||||
>
|
||||
{getLanguageName(loc)}
|
||||
{LOCALE_LABELS[loc]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
|
||||
import { LOCALE_LABELS } from "@/i18n/config"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,49 +12,25 @@ import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const t = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as any)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
const newPath = segments.join("/")
|
||||
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
const { locale, locales, switchLocale } = useLocaleSwitcher()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" aria-label="Switch language">
|
||||
<Languages className="h-5 w-5" />
|
||||
<span className="sr-only">{t("lang.switch")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("en")}
|
||||
className={locale === "en" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.en")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("zh-CN")}
|
||||
className={locale === "zh-CN" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.zhCN")}
|
||||
</DropdownMenuItem>
|
||||
{locales.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={locale === loc ? "bg-accent" : ""}
|
||||
>
|
||||
{LOCALE_LABELS[loc]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,38 @@ const roleConfigs = {
|
||||
civilian: { key: 'CIVILIAN', icon: User2 },
|
||||
} as const
|
||||
|
||||
const providerConfigs = {
|
||||
google: {
|
||||
label: "Google",
|
||||
className: "text-red-500 bg-red-500/10",
|
||||
icon: (props: any) => (
|
||||
<svg viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
className: "text-primary bg-primary/10",
|
||||
icon: Github,
|
||||
},
|
||||
} as const
|
||||
|
||||
export function ProfileCard({ user }: ProfileCardProps) {
|
||||
const t = useTranslations("profile.card")
|
||||
const tAuth = useTranslations("auth.signButton")
|
||||
@@ -56,15 +88,24 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold truncate">{user.name}</h2>
|
||||
{
|
||||
user.email && (
|
||||
// 先简单实现,后续再完善
|
||||
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
|
||||
<Github className="w-3 h-3" />
|
||||
{tAuth("linked")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{!!user?.providers?.length && (
|
||||
<div className="flex gap-2">
|
||||
{user.providers.map((provider) => {
|
||||
const config = providerConfigs[provider as keyof typeof providerConfigs]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full flex-shrink-0 ${config.className}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
{
|
||||
@@ -78,7 +119,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
const Icon = roleConfig.icon
|
||||
const roleName = t(`roles.${roleConfig.key}` as any)
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
|
||||
title={roleName}
|
||||
@@ -110,15 +151,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
{canManageWebhook && <ApiKeyPanel />}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => router.push(`/${locale}/moe`)}
|
||||
className="gap-2 flex-1"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{tNav("backToMailbox")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signOut({ callbackUrl: `/${locale}` })}
|
||||
className="flex-1"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useToast } from "@/components/ui/use-toast"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,6 +26,10 @@ export function WebsiteConfigPanel() {
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false)
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState("")
|
||||
const [turnstileSecretKey, setTurnstileSecretKey] = useState("")
|
||||
const [showSecretKey, setShowSecretKey] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -38,12 +45,20 @@ export function WebsiteConfigPanel() {
|
||||
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
|
||||
emailDomains: string,
|
||||
adminContact: string,
|
||||
maxEmails: string
|
||||
maxEmails: string,
|
||||
turnstile?: {
|
||||
enabled: boolean,
|
||||
siteKey: string,
|
||||
secretKey?: string
|
||||
}
|
||||
}
|
||||
setDefaultRole(data.defaultRole)
|
||||
setEmailDomains(data.emailDomains)
|
||||
setAdminContact(data.adminContact)
|
||||
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
|
||||
setTurnstileEnabled(Boolean(data.turnstile?.enabled))
|
||||
setTurnstileSiteKey(data.turnstile?.siteKey ?? "")
|
||||
setTurnstileSecretKey(data.turnstile?.secretKey ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +72,12 @@ export function WebsiteConfigPanel() {
|
||||
defaultRole,
|
||||
emailDomains,
|
||||
adminContact,
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
|
||||
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString(),
|
||||
turnstile: {
|
||||
enabled: turnstileEnabled,
|
||||
siteKey: turnstileSiteKey,
|
||||
secretKey: turnstileSecretKey
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -136,6 +156,63 @@ export function WebsiteConfigPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-dashed border-primary/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="turnstile-enabled" className="text-sm font-medium">
|
||||
{t("turnstile.enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("turnstile.enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="turnstile-enabled"
|
||||
checked={turnstileEnabled}
|
||||
onCheckedChange={setTurnstileEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile-site-key" className="text-sm font-medium">
|
||||
{t("turnstile.siteKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="turnstile-site-key"
|
||||
value={turnstileSiteKey}
|
||||
onChange={(e) => setTurnstileSiteKey(e.target.value)}
|
||||
placeholder={t("turnstile.siteKeyPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile-secret-key" className="text-sm font-medium">
|
||||
{t("turnstile.secretKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="turnstile-secret-key"
|
||||
type={showSecretKey ? "text" : "password"}
|
||||
value={turnstileSecretKey}
|
||||
onChange={(e) => setTurnstileSecretKey(e.target.value)}
|
||||
placeholder={t("turnstile.secretKeyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowSecretKey((prev) => !prev)}
|
||||
>
|
||||
{showSecretKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("turnstile.secretKeyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
@@ -146,4 +223,4 @@ export function WebsiteConfigPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,24 @@ import { ExternalLink, Mail } from "lucide-react"
|
||||
interface BrandHeaderProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
showCta?: boolean
|
||||
ctaText?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
export function BrandHeader({
|
||||
title,
|
||||
export function BrandHeader({
|
||||
title,
|
||||
subtitle,
|
||||
showCta = true,
|
||||
ctaText,
|
||||
ctaHref = "https://moemail.app"
|
||||
}: BrandHeaderProps) {
|
||||
const t = useTranslations("emails.shared.brand")
|
||||
|
||||
|
||||
const displayTitle = title || t("title")
|
||||
const displaySubtitle = subtitle || t("subtitle")
|
||||
const displayCtaText = ctaText || t("cta")
|
||||
return (
|
||||
<div className="text-center space-y-4 lg:pb-4">
|
||||
<div className="flex justify-center pt-2">
|
||||
<Link
|
||||
href={ctaHref}
|
||||
<Link
|
||||
href="https://moemail.app"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||
>
|
||||
<div className="relative w-12 h-12">
|
||||
@@ -47,32 +43,32 @@ export function BrandHeader({
|
||||
d="M4 8h24v16H4V8z"
|
||||
className="fill-primary/20"
|
||||
/>
|
||||
|
||||
|
||||
{/* 信封边框 */}
|
||||
<path
|
||||
d="M4 8h24v2H4V8zM4 22h24v2H4v-2z"
|
||||
className="fill-primary"
|
||||
/>
|
||||
|
||||
|
||||
{/* @ 符号 */}
|
||||
<path
|
||||
d="M14 12h4v4h-4v-4zM12 14h2v4h-2v-4zM18 14h2v4h-2v-4zM14 18h4v2h-4v-2z"
|
||||
className="fill-primary"
|
||||
/>
|
||||
|
||||
|
||||
{/* 折线装饰 */}
|
||||
<path
|
||||
d="M4 8l12 8 12-8"
|
||||
className="stroke-primary stroke-2"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
|
||||
{/* 装饰点 */}
|
||||
<path
|
||||
d="M8 18h2v2H8v-2zM22 18h2v2h-2v-2z"
|
||||
className="fill-primary/60"
|
||||
/>
|
||||
|
||||
|
||||
{/* 底部装饰线 */}
|
||||
<path
|
||||
d="M8 14h2v2H8v-2zM22 14h2v2h-2v-2z"
|
||||
@@ -96,21 +92,19 @@ export function BrandHeader({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCta && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
|
||||
>
|
||||
<Link href={ctaHref} target="_blank" rel="noopener noreferrer">
|
||||
<Mail className="w-5 h-5" />
|
||||
{displayCtaText}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
|
||||
>
|
||||
<Link href="/" target="_blank" rel="noopener noreferrer">
|
||||
<Mail className="w-5 h-5" />
|
||||
{displayCtaText}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
38
app/hooks/use-locale-switcher.ts
Normal file
38
app/hooks/use-locale-switcher.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useLocale } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n, type Locale } from "@/i18n/config"
|
||||
|
||||
export function useLocaleSwitcher() {
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = useCallback(
|
||||
(newLocale: Locale) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as Locale)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
|
||||
router.push(segments.join("/"))
|
||||
router.refresh()
|
||||
},
|
||||
[locale, pathname, router]
|
||||
)
|
||||
|
||||
return {
|
||||
locale,
|
||||
switchLocale,
|
||||
locales: i18n.locales,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export const locales = ['en', 'zh-CN'] as const
|
||||
export const locales = ['en', 'zh-CN', 'zh-TW', 'ja', 'ko'] as const
|
||||
export type Locale = typeof locales[number]
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
}
|
||||
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
export const i18n = {
|
||||
@@ -8,4 +16,3 @@ export const i18n = {
|
||||
defaultLocale,
|
||||
localePrefix: 'as-needed',
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"login": "Login",
|
||||
"register": "Sign Up",
|
||||
"or": "OR",
|
||||
"githubLogin": "Login with GitHub"
|
||||
"githubLogin": "Login with GitHub",
|
||||
"googleLogin": "Login with Google"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "Please enter username",
|
||||
@@ -36,8 +37,11 @@
|
||||
"loginFailedDesc": "Incorrect username or password",
|
||||
"registerFailed": "Registration Failed",
|
||||
"registerFailedDesc": "Please try again later",
|
||||
"autoLoginFailed": "Auto-login failed, please login manually"
|
||||
"autoLoginFailed": "Auto-login failed, please login manually",
|
||||
"turnstileRequired": "Please complete the verification",
|
||||
"turnstileRequiredDesc": "Solve the Turnstile challenge below before continuing",
|
||||
"registerSuccess": "Registration Successful",
|
||||
"registerSuccessDesc": "Switch to the login tab and complete verification to sign in"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
"zhCN": "简体中文",
|
||||
"switch": "Switch Language"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
@@ -22,5 +17,3 @@
|
||||
},
|
||||
"github": "Get Source Code"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "Insufficient Permission",
|
||||
"description": "You don't have permission to access this page. Please contact the website administrator.",
|
||||
"adminContact": "Admin Contact",
|
||||
"backToHome": "Back to Home"
|
||||
"backToHome": "Back to Home",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "My Emails",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "Admin Contact",
|
||||
"adminContactPlaceholder": "Email or other contact method",
|
||||
"maxEmails": "Max Emails per User",
|
||||
"turnstile": {
|
||||
"enable": "Enable Cloudflare Turnstile",
|
||||
"enableDescription": "When enabled, username/password login and registration require Turnstile verification",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Enter Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Enter Turnstile Secret Key",
|
||||
"secretKeyDescription": "Set up a Turnstile application in Cloudflare and provide the required keys before enabling"
|
||||
},
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "Failed to update user role"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
app/i18n/messages/ja/auth.json
Normal file
47
app/i18n/messages/ja/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "ログイン/登録",
|
||||
"logout": "ログアウト",
|
||||
"userAvatar": "ユーザーアバター",
|
||||
"linked": "連携済み"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "MoeMailへようこそ",
|
||||
"subtitle": "かわいい使い捨てメールサービス (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "ログイン",
|
||||
"register": "登録"
|
||||
},
|
||||
"fields": {
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワードを確認"
|
||||
},
|
||||
"actions": {
|
||||
"login": "ログイン",
|
||||
"register": "登録",
|
||||
"or": "または",
|
||||
"githubLogin": "GitHub アカウントでログイン",
|
||||
"googleLogin": "Google アカウントでログイン"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "ユーザー名を入力してください",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"confirmPasswordRequired": "パスワードを確認してください",
|
||||
"usernameInvalid": "ユーザー名に @ を含めることはできません",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"passwordMismatch": "パスワードが一致しません"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"loginFailedDesc": "ユーザー名またはパスワードが正しくありません",
|
||||
"registerFailed": "登録に失敗しました",
|
||||
"registerFailedDesc": "しばらくしてからもう一度お試しください",
|
||||
"autoLoginFailed": "自動ログインに失敗しました。手動でログインしてください",
|
||||
"turnstileRequired": "まず認証を完了してください",
|
||||
"turnstileRequiredDesc": "続行する前に下の Turnstile 認証を完了してください",
|
||||
"registerSuccess": "登録が完了しました",
|
||||
"registerSuccessDesc": "ログインタブに切り替え、認証を完了してログインしてください"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/i18n/messages/ja/common.json
Normal file
20
app/i18n/messages/ja/common.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "OK",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"delete": "削除"
|
||||
},
|
||||
"nav": {
|
||||
"home": "ホーム",
|
||||
"login": "ログイン",
|
||||
"profile": "プロフィール",
|
||||
"logout": "ログアウト",
|
||||
"backToMailbox": "メールボックスに戻る"
|
||||
},
|
||||
"github": "ソースコードを入手"
|
||||
}
|
||||
|
||||
169
app/i18n/messages/ja/emails.json
Normal file
169
app/i18n/messages/ja/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "権限がありません",
|
||||
"description": "このページにアクセスする権限がありません。サイト管理者に連絡してください",
|
||||
"adminContact": "管理者の連絡先",
|
||||
"backToHome": "ホームに戻る",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "マイメールボックス",
|
||||
"selectEmail": "メールボックスを選択してメッセージを表示",
|
||||
"messageContent": "メール内容",
|
||||
"selectMessage": "メールを選択して詳細を表示",
|
||||
"backToEmailList": "← メールボックス一覧に戻る",
|
||||
"backToMessageList": "← メッセージ一覧に戻る"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 個のメールボックス",
|
||||
"emailCountUnlimited": "{count}/∞ 個のメールボックス",
|
||||
"loading": "読み込み中...",
|
||||
"loadingMore": "さらに読み込み中...",
|
||||
"noEmails": "まだメールボックスがありません。作成しましょう!",
|
||||
"expiresAt": "有効期限",
|
||||
"permanent": "永久",
|
||||
"deleteConfirm": "削除の確認",
|
||||
"deleteDescription": "メールボックス {email} を削除しますか?この操作によってメールボックス内のすべてのメールが削除され、元に戻せません。",
|
||||
"deleteSuccess": "メールボックスを削除しました",
|
||||
"deleteFailed": "メールボックスの削除に失敗しました",
|
||||
"error": "エラー",
|
||||
"success": "成功"
|
||||
},
|
||||
"create": {
|
||||
"title": "メールボックスを作成",
|
||||
"name": "メールボックスのプレフィックス",
|
||||
"namePlaceholder": "空白の場合はランダムに生成",
|
||||
"domain": "ドメイン",
|
||||
"domainPlaceholder": "ドメインを選択",
|
||||
"expiryTime": "有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"permanent": "永久",
|
||||
"create": "作成",
|
||||
"creating": "作成中...",
|
||||
"success": "メールボックスを作成しました",
|
||||
"failed": "メールボックスの作成に失敗しました"
|
||||
},
|
||||
"messages": {
|
||||
"received": "受信箱",
|
||||
"sent": "送信済み",
|
||||
"noMessages": "メールはまだありません",
|
||||
"messageCount": "件のメール",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"subject": "件名",
|
||||
"date": "日付",
|
||||
"loading": "読み込み中...",
|
||||
"loadingMore": "さらに読み込み中..."
|
||||
},
|
||||
"send": {
|
||||
"title": "メールを送信",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"toPlaceholder": "宛先メールアドレス",
|
||||
"subject": "件名",
|
||||
"subjectPlaceholder": "メールの件名",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "メール内容(HTML 対応)",
|
||||
"send": "送信",
|
||||
"sending": "送信中...",
|
||||
"success": "メールを送信しました",
|
||||
"failed": "メールの送信に失敗しました",
|
||||
"dailyLimitReached": "1日の送信上限に達しました",
|
||||
"dailyLimit": "1日の上限: {count}/{max}",
|
||||
"dailyLimitUnit": "通/日"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "メール詳細を読み込み中...",
|
||||
"loadError": "メール詳細の取得に失敗しました",
|
||||
"networkError": "ネットワークエラーが発生しました。しばらくしてから再試行してください",
|
||||
"retry": "クリックして再試行",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"time": "時間",
|
||||
"htmlFormat": "HTML 表示",
|
||||
"textFormat": "テキスト表示"
|
||||
},
|
||||
"share": {
|
||||
"title": "メールボックスを共有",
|
||||
"description": "共有リンクを作成して、このメールボックス内のメールを他の人に見せましょう",
|
||||
"createLink": "リンクを作成",
|
||||
"creating": "作成中...",
|
||||
"loading": "読み込み中...",
|
||||
"expiryTime": "リンクの有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"oneWeek": "1週間",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "現在の共有リンク",
|
||||
"noLinks": "共有リンクはまだありません",
|
||||
"createdAt": "作成日時",
|
||||
"expiresAt": "期限",
|
||||
"expired": "期限切れ",
|
||||
"copy": "リンクをコピー",
|
||||
"copied": "コピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleteConfirm": "共有リンクを削除しますか?",
|
||||
"deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
|
||||
"cancel": "キャンセル",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"createSuccess": "共有リンクを作成しました",
|
||||
"createFailed": "共有リンクの作成に失敗しました",
|
||||
"shareButton": "共有"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "メールを共有",
|
||||
"description": "共有リンクを作成して、このメールを他の人に見せましょう",
|
||||
"createLink": "リンクを作成",
|
||||
"creating": "作成中...",
|
||||
"loading": "読み込み中...",
|
||||
"expiryTime": "リンクの有効期間",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "1日",
|
||||
"threeDays": "3日",
|
||||
"oneWeek": "1週間",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "現在の共有リンク",
|
||||
"noLinks": "共有リンクはまだありません",
|
||||
"createdAt": "作成日時",
|
||||
"expiresAt": "期限",
|
||||
"expired": "期限切れ",
|
||||
"copy": "リンクをコピー",
|
||||
"copied": "コピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleteConfirm": "共有リンクを削除しますか?",
|
||||
"deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
|
||||
"cancel": "キャンセル",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"createSuccess": "共有リンクを作成しました",
|
||||
"createFailed": "共有リンクの作成に失敗しました",
|
||||
"shareButton": "メールを共有"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "読み込み中...",
|
||||
"emailNotFound": "メールボックスにアクセスできません",
|
||||
"messageNotFound": "メールにアクセスできません",
|
||||
"linkExpired": "共有リンクが存在しないか期限切れです",
|
||||
"linkInvalid": "リンクが無効です",
|
||||
"linkInvalidDescription": "この共有リンクは期限切れか存在しない可能性があります",
|
||||
"sharedMailbox": "共有メールボックス",
|
||||
"sharedMessage": "共有メール",
|
||||
"expiresAt": "メールボックスの期限",
|
||||
"permanent": "永久",
|
||||
"createOwnEmail": "自分の使い捨てメールを作成する",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "かわいい使い捨てメールサービス",
|
||||
"cta": "今すぐ体験",
|
||||
"officialSite": "公式サイト",
|
||||
"copyright": "© 2024 MoeMail. かわいい使い捨てメールサービス"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/i18n/messages/ja/home.json
Normal file
27
app/i18n/messages/ja/home.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "かわいい使い捨てメールサービス",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "プライバシー保護",
|
||||
"description": "本物のメールアドレスを保護します"
|
||||
},
|
||||
"instant": {
|
||||
"title": "メールボックス共有",
|
||||
"description": "メールボックスを他の人と共有できます"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "自動期限切れ",
|
||||
"description": "有効期限が切れると自動的に無効になります"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "オープンAPI",
|
||||
"description": "完全な OpenAPI インターフェースを提供"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "メールボックスに入る",
|
||||
"getStarted": "今すぐ始める"
|
||||
}
|
||||
}
|
||||
|
||||
6
app/i18n/messages/ja/metadata.json
Normal file
6
app/i18n/messages/ja/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - かわいい使い捨てメールサービス · オープンAPI",
|
||||
"description": "安全で高速な使い捨てメールアドレスでプライバシーを守り、スパムを遠ざけます。メールボックス共有、即時受信、期限到来での自動失効に対応。完全な OpenAPI を提供し、開発者の統合や自動テストに最適です。",
|
||||
"keywords": "使い捨てメール, 一時メール, 匿名メール, メール共有, プライバシー保護, スパム対策, 即時受信, 自動失効, 安全なメール, 登録確認, テスト用メール, 電話番号不要, 開発テスト, 自動テスト, メールAPI, OpenAPI, APIインターフェース, RESTful API, APIキー, 開発者ツール, MoeMail"
|
||||
}
|
||||
|
||||
139
app/i18n/messages/ja/profile.json
Normal file
139
app/i18n/messages/ja/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "プロフィール",
|
||||
"card": {
|
||||
"title": "ユーザー情報",
|
||||
"name": "ユーザー名",
|
||||
"role": "ロール",
|
||||
"roles": {
|
||||
"EMPEROR": "皇帝",
|
||||
"DUKE": "公爵",
|
||||
"KNIGHT": "騎士",
|
||||
"CIVILIAN": "市民"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 管理",
|
||||
"description": "OpenAPI にアクセスするための API キーを作成・管理します",
|
||||
"create": "API Key を作成",
|
||||
"name": "キー名",
|
||||
"namePlaceholder": "キー名を入力",
|
||||
"key": "API Key",
|
||||
"createdAt": "作成日時",
|
||||
"copy": "コピー",
|
||||
"delete": "削除",
|
||||
"noKeys": "API Key はまだありません",
|
||||
"createSuccess": "API Key を作成しました",
|
||||
"createFailed": "API Key の作成に失敗しました",
|
||||
"deleteConfirm": "削除の確認",
|
||||
"deleteDescription": "API Key {name} を削除しますか?この操作は元に戻せません。",
|
||||
"deleteSuccess": "API Key を削除しました",
|
||||
"deleteFailed": "API Key の削除に失敗しました",
|
||||
"viewDocs": "ドキュメントを見る",
|
||||
"docs": {
|
||||
"getConfig": "システム設定を取得",
|
||||
"generateEmail": "使い捨てメールを生成",
|
||||
"getEmails": "メールボックス一覧を取得",
|
||||
"getMessages": "メール一覧を取得",
|
||||
"getMessage": "メールを1件取得",
|
||||
"createEmailShare": "メールボックス共有リンクを作成",
|
||||
"getEmailShares": "メールボックス共有リンク一覧を取得",
|
||||
"deleteEmailShare": "メールボックス共有リンクを削除",
|
||||
"createMessageShare": "メール共有リンクを作成",
|
||||
"getMessageShares": "メール共有リンク一覧を取得",
|
||||
"deleteMessageShare": "メール共有リンクを削除",
|
||||
"notes": "注意:",
|
||||
"note1": "YOUR_API_KEY を実際の API Key に置き換えてください",
|
||||
"note2": "/api/config エンドポイントで利用可能なメールボックスドメインなどのシステム設定を取得できます",
|
||||
"note3": "emailId はメールボックスの一意な識別子です",
|
||||
"note4": "messageId はメールの一意な識別子です",
|
||||
"note5": "expiryTime はメールボックスの有効期間(ミリ秒)です。利用可能な値: 3600000(1時間)、86400000(1日)、604800000(7日)、0(永久)",
|
||||
"note6": "domain はメールボックスのドメインで、/api/config エンドポイントで利用可能な一覧を取得できます",
|
||||
"note7": "cursor はページネーション用で、前回のレスポンスから nextCursor を取得してください",
|
||||
"note8": "すべてのリクエストには X-API-Key ヘッダーが必要です",
|
||||
"note9": "expiresIn は共有リンクの有効期間(ミリ秒)で、0 は永久を意味します",
|
||||
"note10": "shareId は共有記録の一意な識別子です"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 送信サービス設定",
|
||||
"configRoleLabel": "設定可能なロール権限",
|
||||
"enable": "メール送信を有効にする",
|
||||
"fixedRoleLimits": "固定権限ルール",
|
||||
"emperorLimit": "皇帝は制限なく無制限に送信できます",
|
||||
"civilianLimit": "市民は送信できません",
|
||||
"enableDescription": "有効にすると Resend を利用してメールを送信します",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Resend API Key を入力",
|
||||
"dailyLimit": "1日の上限",
|
||||
"roleLimits": "送信機能を許可するロール",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました",
|
||||
"unlimited": "無制限",
|
||||
"disabled": "送信権限は無効です",
|
||||
"enabled": "送信権限が有効です"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 設定",
|
||||
"description": "新しいメールを受信した際に指定した URL に通知します",
|
||||
"description2": "この URL に新しいメール情報を含む POST リクエストを送信します",
|
||||
"description3": "データ形式を見る",
|
||||
"enable": "Webhook を有効化",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Webhook URL を入力",
|
||||
"test": "テスト",
|
||||
"testing": "テスト中...",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました",
|
||||
"testSuccess": "Webhook テストに成功しました",
|
||||
"testFailed": "Webhook テストに失敗しました",
|
||||
"docs": {
|
||||
"intro": "新しいメールを受信すると、設定した URL に POST リクエストを送信します。リクエストヘッダーには以下が含まれます:",
|
||||
"exampleBody": "リクエストボディ例:",
|
||||
"subject": "メール件名",
|
||||
"content": "メールテキスト内容",
|
||||
"html": "メール HTML 内容"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "サイト設定",
|
||||
"description": "サイトの設定を管理(皇帝のみ利用可能)",
|
||||
"defaultRole": "新規ユーザーのデフォルトロール",
|
||||
"emailDomains": "メールボックスドメイン",
|
||||
"emailDomainsPlaceholder": "複数のドメインはカンマで区切ります",
|
||||
"adminContact": "管理者連絡先",
|
||||
"adminContactPlaceholder": "メールまたはその他の連絡先",
|
||||
"maxEmails": "ユーザーあたりの最大メールボックス数",
|
||||
"turnstile": {
|
||||
"enable": "Cloudflare Turnstile を有効化",
|
||||
"enableDescription": "有効にすると、ユーザー名とパスワードでのログイン・登録に Turnstile 認証が必要になります",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Turnstile Site Key を入力",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Turnstile Secret Key を入力",
|
||||
"secretKeyDescription": "Cloudflare で Turnstile を作成し、必須のキーを入力してから有効化してください"
|
||||
},
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました"
|
||||
},
|
||||
"promote": {
|
||||
"title": "ロール管理",
|
||||
"description": "ユーザーロールを管理(皇帝のみ利用可能)",
|
||||
"search": "ユーザーを検索",
|
||||
"searchPlaceholder": "ユーザー名またはメールを入力",
|
||||
"username": "ユーザー名",
|
||||
"email": "メール",
|
||||
"role": "ロール",
|
||||
"actions": "操作",
|
||||
"promote": "設定",
|
||||
"noUsers": "ユーザーが見つかりません",
|
||||
"loading": "読み込み中...",
|
||||
"updateSuccess": "ユーザーロールを更新しました",
|
||||
"updateFailed": "ユーザーロールの更新に失敗しました"
|
||||
}
|
||||
}
|
||||
47
app/i18n/messages/ko/auth.json
Normal file
47
app/i18n/messages/ko/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "로그인 / 회원가입",
|
||||
"logout": "로그아웃",
|
||||
"userAvatar": "사용자 아바타",
|
||||
"linked": "연결됨"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "MoeMail에 오신 것을 환영합니다",
|
||||
"subtitle": "귀여운 임시 이메일 서비스 (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "로그인",
|
||||
"register": "회원가입"
|
||||
},
|
||||
"fields": {
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인"
|
||||
},
|
||||
"actions": {
|
||||
"login": "로그인",
|
||||
"register": "회원가입",
|
||||
"or": "또는",
|
||||
"githubLogin": "GitHub로 로그인",
|
||||
"googleLogin": "Google로 로그인"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "사용자 이름을 입력해주세요",
|
||||
"passwordRequired": "비밀번호를 입력해주세요",
|
||||
"confirmPasswordRequired": "비밀번호를 확인해주세요",
|
||||
"usernameInvalid": "사용자 이름에 @ 기호를 포함할 수 없습니다",
|
||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordMismatch": "비밀번호가 일치하지 않습니다"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "로그인 실패",
|
||||
"loginFailedDesc": "사용자 이름 또는 비밀번호가 올바르지 않습니다",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"registerFailedDesc": "나중에 다시 시도해주세요",
|
||||
"autoLoginFailed": "자동 로그인 실패, 수동으로 로그인해주세요",
|
||||
"turnstileRequired": "인증을 완료해주세요",
|
||||
"turnstileRequiredDesc": "계속하기 전에 아래의 Turnstile 인증을 완료해주세요",
|
||||
"registerSuccess": "회원가입 성공",
|
||||
"registerSuccessDesc": "로그인 탭으로 이동하여 인증을 완료하고 로그인해주세요"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/i18n/messages/ko/common.json
Normal file
19
app/i18n/messages/ko/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "확인",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"delete": "삭제"
|
||||
},
|
||||
"nav": {
|
||||
"home": "홈",
|
||||
"login": "로그인",
|
||||
"profile": "프로필",
|
||||
"logout": "로그아웃",
|
||||
"backToMailbox": "메일함으로 돌아가기"
|
||||
},
|
||||
"github": "소스 코드 받기"
|
||||
}
|
||||
169
app/i18n/messages/ko/emails.json
Normal file
169
app/i18n/messages/ko/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "권한 부족",
|
||||
"description": "이 페이지에 접근할 권한이 없습니다. 웹사이트 관리자에게 문의하세요.",
|
||||
"adminContact": "관리자 연락처",
|
||||
"backToHome": "홈으로 돌아가기",
|
||||
"needPermission": "API Key를 관리하려면 공작 이상의 권한이 필요합니다",
|
||||
"contactAdmin": "역할 업그레이드를 위해 웹사이트 관리자에게 문의하세요"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "내 이메일",
|
||||
"selectEmail": "메시지를 보려면 이메일을 선택하세요",
|
||||
"messageContent": "메시지 내용",
|
||||
"selectMessage": "세부 정보를 보려면 메시지를 선택하세요",
|
||||
"backToEmailList": "← 이메일 목록으로 돌아가기",
|
||||
"backToMessageList": "← 메시지 목록으로 돌아가기"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 이메일",
|
||||
"emailCountUnlimited": "{count}/∞ 이메일",
|
||||
"loading": "로딩 중...",
|
||||
"loadingMore": "더 불러오는 중...",
|
||||
"noEmails": "아직 이메일이 없습니다. 하나 만들어보세요!",
|
||||
"expiresAt": "만료",
|
||||
"permanent": "영구",
|
||||
"deleteConfirm": "삭제 확인",
|
||||
"deleteDescription": "{email}을(를) 삭제하시겠습니까? 이 메일함의 모든 메시지도 함께 삭제되며 되돌릴 수 없습니다.",
|
||||
"deleteSuccess": "이메일이 성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "이메일 삭제에 실패했습니다",
|
||||
"error": "오류",
|
||||
"success": "성공"
|
||||
},
|
||||
"create": {
|
||||
"title": "이메일 생성",
|
||||
"name": "이메일 접두사",
|
||||
"namePlaceholder": "비워두면 무작위로 생성됩니다",
|
||||
"domain": "도메인",
|
||||
"domainPlaceholder": "도메인 선택",
|
||||
"expiryTime": "유효 기간",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"permanent": "영구",
|
||||
"create": "생성",
|
||||
"creating": "생성 중...",
|
||||
"success": "이메일이 성공적으로 생성되었습니다",
|
||||
"failed": "이메일 생성에 실패했습니다"
|
||||
},
|
||||
"messages": {
|
||||
"received": "받은 편지함",
|
||||
"sent": "보낸 편지함",
|
||||
"noMessages": "아직 메시지가 없습니다",
|
||||
"messageCount": "메시지",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"subject": "제목",
|
||||
"date": "날짜",
|
||||
"loading": "로딩 중...",
|
||||
"loadingMore": "더 불러오는 중..."
|
||||
},
|
||||
"send": {
|
||||
"title": "이메일 보내기",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"toPlaceholder": "받는 사람 이메일 주소",
|
||||
"subject": "제목",
|
||||
"subjectPlaceholder": "이메일 제목",
|
||||
"content": "내용",
|
||||
"contentPlaceholder": "이메일 내용 (HTML 지원)",
|
||||
"send": "보내기",
|
||||
"sending": "보내는 중...",
|
||||
"success": "이메일이 성공적으로 전송되었습니다",
|
||||
"failed": "이메일 전송에 실패했습니다",
|
||||
"dailyLimitReached": "일일 전송 제한에 도달했습니다",
|
||||
"dailyLimit": "일일 제한: {count}/{max}",
|
||||
"dailyLimitUnit": "이메일/일"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "메시지 세부 정보 로딩 중...",
|
||||
"loadError": "메시지 세부 정보 로드 실패",
|
||||
"networkError": "네트워크 오류, 나중에 다시 시도해주세요",
|
||||
"retry": "다시 시도하려면 클릭하세요",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"time": "시간",
|
||||
"htmlFormat": "HTML 형식",
|
||||
"textFormat": "일반 텍스트 형식"
|
||||
},
|
||||
"share": {
|
||||
"title": "메일함 공유",
|
||||
"description": "다른 사람이 이 메일함의 이메일을 볼 수 있도록 공유 링크 생성",
|
||||
"createLink": "링크 생성",
|
||||
"creating": "생성 중...",
|
||||
"loading": "로딩 중...",
|
||||
"expiryTime": "링크 만료",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"oneWeek": "1주",
|
||||
"permanent": "영구",
|
||||
"activeLinks": "활성 공유 링크",
|
||||
"noLinks": "아직 공유 링크가 없습니다",
|
||||
"createdAt": "생성됨",
|
||||
"expiresAt": "만료",
|
||||
"expired": "만료됨",
|
||||
"copy": "링크 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "복사 실패",
|
||||
"delete": "삭제",
|
||||
"deleteConfirm": "공유 링크를 삭제하시겠습니까?",
|
||||
"deleteDescription": "이 작업은 되돌릴 수 없습니다. 공유 링크가 즉시 무효화됩니다.",
|
||||
"cancel": "취소",
|
||||
"deleteSuccess": "성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "삭제에 실패했습니다",
|
||||
"createSuccess": "공유 링크가 성공적으로 생성되었습니다",
|
||||
"createFailed": "공유 링크 생성에 실패했습니다",
|
||||
"shareButton": "공유"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "메시지 공유",
|
||||
"description": "다른 사람이 이 메시지를 볼 수 있도록 공유 링크 생성",
|
||||
"createLink": "링크 생성",
|
||||
"creating": "생성 중...",
|
||||
"loading": "로딩 중...",
|
||||
"expiryTime": "링크 만료",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "1일",
|
||||
"threeDays": "3일",
|
||||
"oneWeek": "1주",
|
||||
"permanent": "영구",
|
||||
"activeLinks": "활성 공유 링크",
|
||||
"noLinks": "아직 공유 링크가 없습니다",
|
||||
"createdAt": "생성됨",
|
||||
"expiresAt": "만료",
|
||||
"expired": "만료됨",
|
||||
"copy": "링크 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "복사 실패",
|
||||
"delete": "삭제",
|
||||
"deleteConfirm": "공유 링크를 삭제하시겠습니까?",
|
||||
"deleteDescription": "이 작업은 되돌릴 수 없습니다. 공유 링크가 즉시 무효화됩니다.",
|
||||
"cancel": "취소",
|
||||
"deleteSuccess": "성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "삭제에 실패했습니다",
|
||||
"createSuccess": "공유 링크가 성공적으로 생성되었습니다",
|
||||
"createFailed": "공유 링크 생성에 실패했습니다",
|
||||
"shareButton": "메시지 공유"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "로딩 중...",
|
||||
"emailNotFound": "메일함에 접근할 수 없습니다",
|
||||
"messageNotFound": "메시지에 접근할 수 없습니다",
|
||||
"linkExpired": "공유 링크가 존재하지 않거나 만료되었습니다",
|
||||
"linkInvalid": "유효하지 않은 링크",
|
||||
"linkInvalidDescription": "이 공유 링크가 만료되었거나 존재하지 않을 수 있습니다",
|
||||
"sharedMailbox": "공유된 메일함",
|
||||
"sharedMessage": "공유된 메시지",
|
||||
"expiresAt": "만료 시간",
|
||||
"permanent": "영구",
|
||||
"createOwnEmail": "나만의 임시 이메일 만들기",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "귀여운 임시 이메일 서비스",
|
||||
"cta": "지금 사용해보기",
|
||||
"officialSite": "공식 사이트",
|
||||
"copyright": "© 2024 MoeMail. 귀여운 임시 이메일 서비스"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/i18n/messages/ko/home.json
Normal file
26
app/i18n/messages/ko/home.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "귀여운 임시 이메일 서비스",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "개인정보 보호",
|
||||
"description": "실제 이메일 주소 보호"
|
||||
},
|
||||
"instant": {
|
||||
"title": "이메일 공유",
|
||||
"description": "다른 사람과 메일함 공유"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "자동 만료",
|
||||
"description": "기한이 되면 자동으로 만료"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "Open API",
|
||||
"description": "전체 OpenAPI 인터페이스 제공"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "메일함 입장",
|
||||
"getStarted": "시작하기"
|
||||
}
|
||||
}
|
||||
5
app/i18n/messages/ko/metadata.json
Normal file
5
app/i18n/messages/ko/metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "MoeMail - 귀여운 임시 이메일 서비스 · Open API",
|
||||
"description": "안전하고 빠르며 일회용 임시 이메일 주소. 개인정보를 보호하고 스팸으로부터 자유로워지세요. 이메일 공유, 자동 만료 기능을 갖춘 즉시 배송. 개발자와 자동화 테스트를 위한 완전한 OpenAPI 인터페이스.",
|
||||
"keywords": "임시 이메일, 일회용 이메일, 익명 이메일, 이메일 공유, 개인정보 보호, 스팸 필터, 즉시 배송, 자동 만료, 안전한 이메일, 인증 이메일, 임시 계정, 전화번호 불필요, 개발자 도구, 자동화 테스트, 이메일 API, OpenAPI, API 인터페이스, RESTful API, API Key, 개발자 친화적, MoeMail"
|
||||
}
|
||||
139
app/i18n/messages/ko/profile.json
Normal file
139
app/i18n/messages/ko/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "프로필",
|
||||
"card": {
|
||||
"title": "사용자 정보",
|
||||
"name": "이름",
|
||||
"role": "역할",
|
||||
"roles": {
|
||||
"EMPEROR": "황제",
|
||||
"DUKE": "공작",
|
||||
"KNIGHT": "기사",
|
||||
"CIVILIAN": "평민"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 관리",
|
||||
"description": "OpenAPI 접근을 위한 API 키 생성 및 관리",
|
||||
"create": "API Key 생성",
|
||||
"name": "키 이름",
|
||||
"namePlaceholder": "키 이름 입력",
|
||||
"key": "API Key",
|
||||
"createdAt": "생성 날짜",
|
||||
"copy": "복사",
|
||||
"delete": "삭제",
|
||||
"noKeys": "아직 API 키가 없습니다",
|
||||
"createSuccess": "API 키가 성공적으로 생성되었습니다",
|
||||
"createFailed": "API 키 생성에 실패했습니다",
|
||||
"deleteConfirm": "삭제 확인",
|
||||
"deleteDescription": "API 키 {name}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"deleteSuccess": "API 키가 성공적으로 삭제되었습니다",
|
||||
"deleteFailed": "API 키 삭제에 실패했습니다",
|
||||
"viewDocs": "문서 보기",
|
||||
"docs": {
|
||||
"getConfig": "시스템 설정 가져오기",
|
||||
"generateEmail": "임시 이메일 생성",
|
||||
"getEmails": "이메일 목록 가져오기",
|
||||
"getMessages": "메시지 목록 가져오기",
|
||||
"getMessage": "단일 메시지 가져오기",
|
||||
"createEmailShare": "이메일 공유 링크 생성",
|
||||
"getEmailShares": "이메일 공유 링크 가져오기",
|
||||
"deleteEmailShare": "이메일 공유 링크 삭제",
|
||||
"createMessageShare": "메시지 공유 링크 생성",
|
||||
"getMessageShares": "메시지 공유 링크 가져오기",
|
||||
"deleteMessageShare": "메시지 공유 링크 삭제",
|
||||
"notes": "참고:",
|
||||
"note1": "YOUR_API_KEY를 실제 API Key로 교체하세요",
|
||||
"note2": "/api/config 엔드포인트는 사용 가능한 이메일 도메인을 포함한 시스템 설정을 제공합니다",
|
||||
"note3": "emailId는 이메일의 고유 식별자입니다",
|
||||
"note4": "messageId는 메시지의 고유 식별자입니다",
|
||||
"note5": "expiryTime은 밀리초 단위의 유효 기간입니다: 3600000 (1시간), 86400000 (1일), 604800000 (7일), 0 (영구)",
|
||||
"note6": "domain은 이메일 도메인이며, /api/config 엔드포인트에서 사용 가능한 도메인을 가져올 수 있습니다",
|
||||
"note7": "cursor는 페이지네이션을 위한 것으로, 이전 응답에서 nextCursor를 가져옵니다",
|
||||
"note8": "모든 요청에는 X-API-Key 헤더가 필요합니다",
|
||||
"note9": "expiresIn은 공유 링크 유효 기간(밀리초)이며, 0은 영구를 의미합니다",
|
||||
"note10": "shareId는 공유 기록의 고유 식별자입니다"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 이메일 서비스 설정",
|
||||
"configRoleLabel": "설정 가능한 역할 권한",
|
||||
"enable": "이메일 서비스 활성화",
|
||||
"fixedRoleLimits": "고정 역할 제한",
|
||||
"emperorLimit": "황제는 제한 없이 무제한 이메일을 보낼 수 있습니다",
|
||||
"civilianLimit": "이메일을 보낼 수 없습니다",
|
||||
"enableDescription": "활성화되면 Resend를 사용하여 이메일을 보냅니다",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Resend API Key 입력",
|
||||
"dailyLimit": "일일 제한",
|
||||
"roleLimits": "전송 기능을 사용할 수 있는 역할",
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다",
|
||||
"unlimited": "무제한",
|
||||
"disabled": "전송 권한이 활성화되지 않음",
|
||||
"enabled": "전송 권한 활성화됨"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 설정",
|
||||
"description": "새 이메일이 도착하면 지정된 URL에 알림",
|
||||
"description2": "새 이메일 정보와 함께 이 URL로 POST 요청을 보냅니다",
|
||||
"description3": "데이터 형식 문서 보기",
|
||||
"enable": "Webhook 활성화",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Webhook URL 입력",
|
||||
"test": "테스트",
|
||||
"testing": "테스트 중...",
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다",
|
||||
"testSuccess": "Webhook 테스트 성공",
|
||||
"testFailed": "Webhook 테스트 실패",
|
||||
"docs": {
|
||||
"intro": "새 이메일을 받으면 다음 헤더와 함께 설정된 URL로 POST 요청을 보냅니다:",
|
||||
"exampleBody": "요청 본문 예시:",
|
||||
"subject": "이메일 제목",
|
||||
"content": "이메일 텍스트 내용",
|
||||
"html": "이메일 HTML 내용"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "웹사이트 설정",
|
||||
"description": "웹사이트 설정 구성 (황제 전용)",
|
||||
"defaultRole": "새 사용자의 기본 역할",
|
||||
"emailDomains": "이메일 도메인",
|
||||
"emailDomainsPlaceholder": "쉼표로 여러 도메인 구분",
|
||||
"adminContact": "관리자 연락처",
|
||||
"adminContactPlaceholder": "이메일 또는 기타 연락 방법",
|
||||
"maxEmails": "사용자당 최대 이메일 수",
|
||||
"turnstile": {
|
||||
"enable": "Cloudflare Turnstile 활성화",
|
||||
"enableDescription": "활성화되면 사용자 이름/비밀번호 로그인 및 회원가입에 Turnstile 인증이 필요합니다",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "Turnstile Site Key 입력",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "Turnstile Secret Key 입력",
|
||||
"secretKeyDescription": "활성화하기 전에 Cloudflare에서 Turnstile 애플리케이션을 설정하고 필요한 키를 제공하세요"
|
||||
},
|
||||
"save": "설정 저장",
|
||||
"saving": "저장 중...",
|
||||
"saveSuccess": "설정이 성공적으로 저장되었습니다",
|
||||
"saveFailed": "설정 저장에 실패했습니다"
|
||||
},
|
||||
"promote": {
|
||||
"title": "역할 관리",
|
||||
"description": "사용자 역할 관리 (황제 전용)",
|
||||
"search": "사용자 검색",
|
||||
"searchPlaceholder": "사용자 이름 또는 이메일 입력",
|
||||
"username": "사용자 이름",
|
||||
"email": "이메일",
|
||||
"role": "역할",
|
||||
"actions": "작업",
|
||||
"promote": "설정",
|
||||
"noUsers": "사용자를 찾을 수 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"updateSuccess": "사용자 역할이 성공적으로 업데이트되었습니다",
|
||||
"updateFailed": "사용자 역할 업데이트에 실패했습니다"
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"or": "或者",
|
||||
"githubLogin": "使用 GitHub 账号登录"
|
||||
"githubLogin": "使用 GitHub 账号登录",
|
||||
"googleLogin": "使用 Google 账号登录"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "请输入用户名",
|
||||
@@ -36,8 +37,11 @@
|
||||
"loginFailedDesc": "用户名或密码错误",
|
||||
"registerFailed": "注册失败",
|
||||
"registerFailedDesc": "请稍后重试",
|
||||
"autoLoginFailed": "自动登录失败,请手动登录"
|
||||
"autoLoginFailed": "自动登录失败,请手动登录",
|
||||
"turnstileRequired": "请先完成人机验证",
|
||||
"turnstileRequiredDesc": "请完成下方的 Turnstile 验证后再尝试",
|
||||
"registerSuccess": "注册成功",
|
||||
"registerSuccessDesc": "请切换到登录选项卡,通过验证后登录账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
"zhCN": "简体中文",
|
||||
"switch": "切换语言"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消",
|
||||
@@ -22,5 +17,3 @@
|
||||
},
|
||||
"github": "获取网站源代码"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "权限不足",
|
||||
"description": "你没有权限访问此页面,请联系网站管理员",
|
||||
"adminContact": "管理员联系方式",
|
||||
"backToHome": "返回首页"
|
||||
"backToHome": "返回首页",
|
||||
"needPermission": "需要公爵或更高权限才能管理 API Key",
|
||||
"contactAdmin": "请联系网站管理员升级您的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的邮箱",
|
||||
@@ -165,4 +167,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
"adminContact": "管理员联系方式",
|
||||
"adminContactPlaceholder": "邮箱或其他联系方式",
|
||||
"maxEmails": "每个用户最大邮箱数",
|
||||
"turnstile": {
|
||||
"enable": "启用 Cloudflare Turnstile",
|
||||
"enableDescription": "开启后,账号登录与注册将需要通过 Turnstile 验证",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "输入 Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "输入 Turnstile Secret Key",
|
||||
"secretKeyDescription": "开启前请先在 Cloudflare 创建 Turnstile,并填写上述必填参数"
|
||||
},
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "配置保存成功",
|
||||
@@ -128,4 +137,3 @@
|
||||
"updateFailed": "更新用户角色失败"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
app/i18n/messages/zh-TW/auth.json
Normal file
47
app/i18n/messages/zh-TW/auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "登入/註冊",
|
||||
"logout": "登出",
|
||||
"userAvatar": "使用者頭像",
|
||||
"linked": "已關聯"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "歡迎使用 MoeMail",
|
||||
"subtitle": "萌萌噠臨時郵箱服務 (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "登入",
|
||||
"register": "註冊"
|
||||
},
|
||||
"fields": {
|
||||
"username": "使用者名稱",
|
||||
"password": "密碼",
|
||||
"confirmPassword": "確認密碼"
|
||||
},
|
||||
"actions": {
|
||||
"login": "登入",
|
||||
"register": "註冊",
|
||||
"or": "或者",
|
||||
"githubLogin": "使用 GitHub 帳號登入",
|
||||
"googleLogin": "使用 Google 帳號登入"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "請輸入使用者名稱",
|
||||
"passwordRequired": "請輸入密碼",
|
||||
"confirmPasswordRequired": "請確認密碼",
|
||||
"usernameInvalid": "使用者名稱不能包含 @ 符號",
|
||||
"passwordTooShort": "密碼長度必須大於等於8位",
|
||||
"passwordMismatch": "兩次輸入的密碼不一致"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "登入失敗",
|
||||
"loginFailedDesc": "使用者名稱或密碼錯誤",
|
||||
"registerFailed": "註冊失敗",
|
||||
"registerFailedDesc": "請稍後再試",
|
||||
"autoLoginFailed": "自動登入失敗,請手動登入",
|
||||
"turnstileRequired": "請先完成驗證",
|
||||
"turnstileRequiredDesc": "請先完成下方的 Turnstile 驗證再繼續",
|
||||
"registerSuccess": "註冊成功",
|
||||
"registerSuccessDesc": "請切換至登入分頁,完成驗證後登入帳號"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/i18n/messages/zh-TW/common.json
Normal file
19
app/i18n/messages/zh-TW/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "確定",
|
||||
"cancel": "取消",
|
||||
"save": "儲存",
|
||||
"delete": "刪除"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首頁",
|
||||
"login": "登入",
|
||||
"profile": "個人中心",
|
||||
"logout": "登出",
|
||||
"backToMailbox": "返回郵箱"
|
||||
},
|
||||
"github": "取得網站原始碼"
|
||||
}
|
||||
169
app/i18n/messages/zh-TW/emails.json
Normal file
169
app/i18n/messages/zh-TW/emails.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "權限不足",
|
||||
"description": "你沒有權限訪問此頁面,請聯絡網站管理員",
|
||||
"adminContact": "管理員聯絡方式",
|
||||
"backToHome": "返回首頁",
|
||||
"needPermission": "需要公爵或更高權限才能管理 API Key",
|
||||
"contactAdmin": "請聯絡網站管理員升級你的角色"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的郵箱",
|
||||
"selectEmail": "選擇郵箱查看訊息",
|
||||
"messageContent": "郵件內容",
|
||||
"selectMessage": "選擇郵件查看詳情",
|
||||
"backToEmailList": "← 返回郵箱列表",
|
||||
"backToMessageList": "← 返回訊息列表"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 個郵箱",
|
||||
"emailCountUnlimited": "{count}/∞ 個郵箱",
|
||||
"loading": "載入中...",
|
||||
"loadingMore": "載入更多...",
|
||||
"noEmails": "還沒有郵箱,建立一個吧!",
|
||||
"expiresAt": "過期時間",
|
||||
"permanent": "永久有效",
|
||||
"deleteConfirm": "確認刪除",
|
||||
"deleteDescription": "確定要刪除郵箱 {email} 嗎?此操作將同時刪除該郵箱中的所有郵件,且不可恢復。",
|
||||
"deleteSuccess": "郵箱已刪除",
|
||||
"deleteFailed": "刪除郵箱失敗",
|
||||
"error": "錯誤",
|
||||
"success": "成功"
|
||||
},
|
||||
"create": {
|
||||
"title": "建立郵箱",
|
||||
"name": "郵箱前綴",
|
||||
"namePlaceholder": "留空則隨機生成",
|
||||
"domain": "網域",
|
||||
"domainPlaceholder": "選擇網域",
|
||||
"expiryTime": "有效期",
|
||||
"oneHour": "1 小時",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"permanent": "永久",
|
||||
"create": "建立",
|
||||
"creating": "建立中...",
|
||||
"success": "郵箱建立成功",
|
||||
"failed": "建立郵箱失敗"
|
||||
},
|
||||
"messages": {
|
||||
"received": "收件匣",
|
||||
"sent": "已發送",
|
||||
"noMessages": "暫無郵件",
|
||||
"messageCount": "封郵件",
|
||||
"from": "寄件者",
|
||||
"to": "收件者",
|
||||
"subject": "主旨",
|
||||
"date": "日期",
|
||||
"loading": "載入中...",
|
||||
"loadingMore": "載入更多..."
|
||||
},
|
||||
"send": {
|
||||
"title": "發送郵件",
|
||||
"from": "寄件者",
|
||||
"to": "收件者",
|
||||
"toPlaceholder": "收件者郵箱地址",
|
||||
"subject": "主旨",
|
||||
"subjectPlaceholder": "郵件主旨",
|
||||
"content": "內容",
|
||||
"contentPlaceholder": "郵件內容(支援 HTML)",
|
||||
"send": "發送",
|
||||
"sending": "發送中...",
|
||||
"success": "郵件發送成功",
|
||||
"failed": "發送郵件失敗",
|
||||
"dailyLimitReached": "已達每日發送上限",
|
||||
"dailyLimit": "每日限額:{count}/{max}",
|
||||
"dailyLimitUnit": "封/天"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "載入郵件詳情...",
|
||||
"loadError": "取得郵件詳情失敗",
|
||||
"networkError": "網路錯誤,請稍後再試",
|
||||
"retry": "點擊重試",
|
||||
"from": "寄件者",
|
||||
"to": "收件者",
|
||||
"time": "時間",
|
||||
"htmlFormat": "HTML 格式",
|
||||
"textFormat": "純文字格式"
|
||||
},
|
||||
"share": {
|
||||
"title": "分享郵箱",
|
||||
"description": "建立分享連結,讓其他人可以查看此郵箱中的郵件",
|
||||
"createLink": "建立連結",
|
||||
"creating": "建立中...",
|
||||
"loading": "載入中...",
|
||||
"expiryTime": "連結有效期",
|
||||
"oneHour": "1 小時",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"oneWeek": "1 週",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "目前分享連結",
|
||||
"noLinks": "暫無分享連結",
|
||||
"createdAt": "建立時間",
|
||||
"expiresAt": "過期時間",
|
||||
"expired": "已過期",
|
||||
"copy": "複製連結",
|
||||
"copied": "已複製",
|
||||
"copyFailed": "複製失敗",
|
||||
"delete": "刪除",
|
||||
"deleteConfirm": "確認刪除分享連結?",
|
||||
"deleteDescription": "此操作無法撤銷,分享連結將立即失效。",
|
||||
"cancel": "取消",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"createSuccess": "分享連結建立成功",
|
||||
"createFailed": "建立分享連結失敗",
|
||||
"shareButton": "分享"
|
||||
},
|
||||
"shareMessage": {
|
||||
"title": "分享郵件",
|
||||
"description": "建立分享連結,讓其他人可以查看這封郵件",
|
||||
"createLink": "建立連結",
|
||||
"creating": "建立中...",
|
||||
"loading": "載入中...",
|
||||
"expiryTime": "連結有效期",
|
||||
"oneHour": "1 小時",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"oneWeek": "1 週",
|
||||
"permanent": "永久",
|
||||
"activeLinks": "目前分享連結",
|
||||
"noLinks": "暫無分享連結",
|
||||
"createdAt": "建立時間",
|
||||
"expiresAt": "過期時間",
|
||||
"expired": "已過期",
|
||||
"copy": "複製連結",
|
||||
"copied": "已複製",
|
||||
"copyFailed": "複製失敗",
|
||||
"delete": "刪除",
|
||||
"deleteConfirm": "確認刪除分享連結?",
|
||||
"deleteDescription": "此操作無法撤銷,分享連結將立即失效。",
|
||||
"cancel": "取消",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"createSuccess": "分享連結建立成功",
|
||||
"createFailed": "建立分享連結失敗",
|
||||
"shareButton": "分享郵件"
|
||||
},
|
||||
"shared": {
|
||||
"loading": "載入中...",
|
||||
"emailNotFound": "無法訪問郵箱",
|
||||
"messageNotFound": "無法訪問郵件",
|
||||
"linkExpired": "分享連結不存在或已過期",
|
||||
"linkInvalid": "連結無效",
|
||||
"linkInvalidDescription": "此分享連結可能已過期或不存在",
|
||||
"sharedMailbox": "分享郵箱",
|
||||
"sharedMessage": "分享郵件",
|
||||
"expiresAt": "郵箱過期時間",
|
||||
"permanent": "永久有效",
|
||||
"createOwnEmail": "建立自己的臨時郵箱",
|
||||
"brand": {
|
||||
"title": "MoeMail",
|
||||
"subtitle": "萌萌噠臨時郵箱服務",
|
||||
"cta": "立即體驗",
|
||||
"officialSite": "官網",
|
||||
"copyright": "© 2024 MoeMail. 萌萌噠臨時郵箱服務"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/i18n/messages/zh-TW/home.json
Normal file
27
app/i18n/messages/zh-TW/home.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "萌萌噠臨時郵箱服務",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "隱私保護",
|
||||
"description": "保護您的真實郵箱地址"
|
||||
},
|
||||
"instant": {
|
||||
"title": "郵箱分享",
|
||||
"description": "將郵箱分享給其他人使用"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "自動過期",
|
||||
"description": "到期自動失效"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "開放 API",
|
||||
"description": "提供完整的 OpenAPI 介面"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "進入郵箱",
|
||||
"getStarted": "開始使用"
|
||||
}
|
||||
}
|
||||
|
||||
6
app/i18n/messages/zh-TW/metadata.json
Normal file
6
app/i18n/messages/zh-TW/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - 萌萌噠臨時郵箱服務 · 開放 API",
|
||||
"description": "安全、快速、一次性的臨時郵箱地址,保護您的隱私,遠離垃圾郵件。支援郵箱分享、即時收件,到期自動失效。提供完整的 OpenAPI 介面,方便開發者整合與自動化測試。",
|
||||
"keywords": "臨時郵箱, 一次性郵箱, 匿名郵箱, 郵箱分享, 隱私保護, 垃圾郵件過濾, 即時收件, 自動過期, 安全郵箱, 註冊驗證, 臨時帳號, 無需手機號, 開發測試, 自動化測試, 郵件API, OpenAPI, API介面, RESTful API, API Key, 開發者工具, MoeMail"
|
||||
}
|
||||
|
||||
139
app/i18n/messages/zh-TW/profile.json
Normal file
139
app/i18n/messages/zh-TW/profile.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"title": "個人中心",
|
||||
"card": {
|
||||
"title": "使用者資訊",
|
||||
"name": "使用者名稱",
|
||||
"role": "角色",
|
||||
"roles": {
|
||||
"EMPEROR": "皇帝",
|
||||
"DUKE": "公爵",
|
||||
"KNIGHT": "騎士",
|
||||
"CIVILIAN": "平民"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 管理",
|
||||
"description": "建立與管理用於存取 OpenAPI 的 API 金鑰",
|
||||
"create": "建立 API Key",
|
||||
"name": "金鑰名稱",
|
||||
"namePlaceholder": "輸入金鑰名稱",
|
||||
"key": "API Key",
|
||||
"createdAt": "建立時間",
|
||||
"copy": "複製",
|
||||
"delete": "刪除",
|
||||
"noKeys": "暫無 API Key",
|
||||
"createSuccess": "API Key 建立成功",
|
||||
"createFailed": "建立 API Key 失敗",
|
||||
"deleteConfirm": "確認刪除",
|
||||
"deleteDescription": "確定要刪除 API Key {name} 嗎?此操作不可恢復。",
|
||||
"deleteSuccess": "API Key 已刪除",
|
||||
"deleteFailed": "刪除 API Key 失敗",
|
||||
"viewDocs": "查看使用文件",
|
||||
"docs": {
|
||||
"getConfig": "取得系統設定",
|
||||
"generateEmail": "產生臨時郵箱",
|
||||
"getEmails": "取得郵箱清單",
|
||||
"getMessages": "取得郵件清單",
|
||||
"getMessage": "取得單封郵件",
|
||||
"createEmailShare": "建立郵箱分享連結",
|
||||
"getEmailShares": "取得郵箱分享連結清單",
|
||||
"deleteEmailShare": "刪除郵箱分享連結",
|
||||
"createMessageShare": "建立郵件分享連結",
|
||||
"getMessageShares": "取得郵件分享連結清單",
|
||||
"deleteMessageShare": "刪除郵件分享連結",
|
||||
"notes": "注意:",
|
||||
"note1": "請將 YOUR_API_KEY 替換為你的實際 API Key",
|
||||
"note2": "/api/config 介面可取得系統設定,包括可用的郵箱網域清單",
|
||||
"note3": "emailId 是郵箱的唯一識別碼",
|
||||
"note4": "messageId 是郵件的唯一識別碼",
|
||||
"note5": "expiryTime 是郵箱的有效期(毫秒),可選值:3600000(1 小時)、86400000(1 天)、604800000(7 天)、0(永久)",
|
||||
"note6": "domain 是郵箱網域,可透過 /api/config 介面取得可用網域清單",
|
||||
"note7": "cursor 用於分頁,從上一次請求的回應中取得 nextCursor",
|
||||
"note8": "所有請求都需要包含 X-API-Key 請求標頭",
|
||||
"note9": "expiresIn 是分享連結的有效期(毫秒),0 表示永久有效",
|
||||
"note10": "shareId 是分享紀錄的唯一識別碼"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 發件服務設定",
|
||||
"configRoleLabel": "可設定的角色權限",
|
||||
"enable": "啟用郵件服務",
|
||||
"fixedRoleLimits": "固定權限規則",
|
||||
"emperorLimit": "皇帝可以無限發件,不受任何限制",
|
||||
"civilianLimit": "永遠不能發件",
|
||||
"enableDescription": "開啟後將使用 Resend 發送郵件",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "輸入 Resend API Key",
|
||||
"dailyLimit": "每日限額",
|
||||
"roleLimits": "允許使用發件功能的角色",
|
||||
"save": "儲存設定",
|
||||
"saving": "儲存中...",
|
||||
"saveSuccess": "設定儲存成功",
|
||||
"saveFailed": "儲存設定失敗",
|
||||
"unlimited": "無限制",
|
||||
"disabled": "未啟用發件權限",
|
||||
"enabled": "已啟用發件權限"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 設定",
|
||||
"description": "當收到新郵件時通知指定的 URL",
|
||||
"description2": "我們會向此 URL 發送 POST 請求,包含新郵件的相關資訊",
|
||||
"description3": "查看資料格式說明",
|
||||
"enable": "啟用 Webhook",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "輸入 webhook URL",
|
||||
"test": "測試",
|
||||
"testing": "測試中...",
|
||||
"save": "儲存設定",
|
||||
"saving": "儲存中...",
|
||||
"saveSuccess": "設定儲存成功",
|
||||
"saveFailed": "儲存設定失敗",
|
||||
"testSuccess": "Webhook 測試成功",
|
||||
"testFailed": "Webhook 測試失敗",
|
||||
"docs": {
|
||||
"intro": "當收到新郵件時,我們會向設定的 URL 發送 POST 請求,請求標頭包含:",
|
||||
"exampleBody": "請求體範例:",
|
||||
"subject": "郵件主旨",
|
||||
"content": "郵件文字內容",
|
||||
"html": "郵件 HTML 內容"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "網站設定",
|
||||
"description": "設定網站選項(僅皇帝可用)",
|
||||
"defaultRole": "新使用者預設角色",
|
||||
"emailDomains": "郵箱網域",
|
||||
"emailDomainsPlaceholder": "多個網域以逗號分隔",
|
||||
"adminContact": "管理員聯絡方式",
|
||||
"adminContactPlaceholder": "郵箱或其他聯絡方式",
|
||||
"maxEmails": "每個使用者最大郵箱數",
|
||||
"turnstile": {
|
||||
"enable": "啟用 Cloudflare Turnstile",
|
||||
"enableDescription": "啟用後,使用者名稱與密碼的登入/註冊需要通過 Turnstile 驗證",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyPlaceholder": "輸入 Turnstile Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyPlaceholder": "輸入 Turnstile Secret Key",
|
||||
"secretKeyDescription": "請先在 Cloudflare 建立 Turnstile 並填寫上述必填參數後再啟用"
|
||||
},
|
||||
"save": "儲存設定",
|
||||
"saving": "儲存中...",
|
||||
"saveSuccess": "設定儲存成功",
|
||||
"saveFailed": "儲存設定失敗"
|
||||
},
|
||||
"promote": {
|
||||
"title": "角色管理",
|
||||
"description": "管理使用者角色(僅皇帝可用)",
|
||||
"search": "搜尋使用者",
|
||||
"searchPlaceholder": "輸入使用者名稱或郵箱",
|
||||
"username": "使用者名稱",
|
||||
"email": "郵箱",
|
||||
"role": "角色",
|
||||
"actions": "操作",
|
||||
"promote": "設為",
|
||||
"noUsers": "未找到使用者",
|
||||
"loading": "載入中...",
|
||||
"updateSuccess": "使用者角色更新成功",
|
||||
"updateFailed": "更新使用者角色失敗"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import NextAuth from "next-auth"
|
||||
import GitHub from "next-auth/providers/github"
|
||||
import Google from "next-auth/providers/google"
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
||||
import { createDb, Db } from "./db"
|
||||
import { accounts, users, roles, userRoles } from "./schema"
|
||||
@@ -8,9 +9,10 @@ import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { Permission, hasPermission, ROLES, Role } from "./permissions"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { hashPassword, comparePassword } from "@/lib/utils"
|
||||
import { authSchema } from "@/lib/validation"
|
||||
import { authSchema, AuthSchema } from "@/lib/validation"
|
||||
import { generateAvatarUrl } from "./avatar"
|
||||
import { getUserId } from "./apiKey"
|
||||
import { verifyTurnstileToken } from "./turnstile"
|
||||
|
||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
[ROLES.EMPEROR]: "皇帝(网站所有者)",
|
||||
@@ -29,7 +31,7 @@ const getDefaultRole = async (): Promise<Role> => {
|
||||
) {
|
||||
return defaultRole as Role
|
||||
}
|
||||
|
||||
|
||||
return ROLES.CIVILIAN
|
||||
}
|
||||
|
||||
@@ -101,6 +103,12 @@ export const {
|
||||
GitHub({
|
||||
clientId: process.env.AUTH_GITHUB_ID,
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
Google({
|
||||
clientId: process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
@@ -113,26 +121,35 @@ export const {
|
||||
throw new Error("请输入用户名和密码")
|
||||
}
|
||||
|
||||
const { username, password } = credentials
|
||||
const { username, password, turnstileToken } = credentials as Record<string, string | undefined>
|
||||
|
||||
let parsedCredentials: AuthSchema
|
||||
try {
|
||||
authSchema.parse({ username, password })
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
parsedCredentials = authSchema.parse({ username, password, turnstileToken })
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (error) {
|
||||
throw new Error("输入格式不正确")
|
||||
}
|
||||
|
||||
const verification = await verifyTurnstileToken(parsedCredentials.turnstileToken)
|
||||
if (!verification.success) {
|
||||
if (verification.reason === "missing-token") {
|
||||
throw new Error("请先完成安全验证")
|
||||
}
|
||||
throw new Error("安全验证未通过")
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username as string),
|
||||
where: eq(users.username, parsedCredentials.username),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error("用户名或密码错误")
|
||||
}
|
||||
|
||||
const isValid = await comparePassword(password as string, user.password as string)
|
||||
const isValid = await comparePassword(parsedCredentials.password, user.password as string)
|
||||
if (!isValid) {
|
||||
throw new Error("用户名或密码错误")
|
||||
}
|
||||
@@ -186,7 +203,7 @@ export const {
|
||||
where: eq(userRoles.userId, session.user.id),
|
||||
with: { role: true },
|
||||
})
|
||||
|
||||
|
||||
if (!userRoleRecords.length) {
|
||||
const defaultRole = await getDefaultRole()
|
||||
const role = await findOrCreateRole(db, defaultRole)
|
||||
@@ -198,10 +215,16 @@ export const {
|
||||
role: role
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
session.user.roles = userRoleRecords.map(ur => ({
|
||||
name: ur.role.name,
|
||||
}))
|
||||
|
||||
const userAccounts = await db.query.accounts.findMany({
|
||||
where: eq(accounts.userId, session.user.id),
|
||||
})
|
||||
|
||||
session.user.providers = userAccounts.map(account => account.provider)
|
||||
}
|
||||
|
||||
return session
|
||||
@@ -214,7 +237,7 @@ export const {
|
||||
|
||||
export async function register(username: string, password: string) {
|
||||
const db = createDb()
|
||||
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.username, username)
|
||||
})
|
||||
@@ -224,7 +247,7 @@ export async function register(username: string, password: string) {
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
|
||||
const [user] = await db.insert(users)
|
||||
.values({
|
||||
username,
|
||||
|
||||
65
app/lib/turnstile.ts
Normal file
65
app/lib/turnstile.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
|
||||
interface TurnstileConfig {
|
||||
enabled: boolean
|
||||
siteKey: string
|
||||
secretKey: string
|
||||
}
|
||||
|
||||
export async function getTurnstileConfig(): Promise<TurnstileConfig> {
|
||||
const env = getRequestContext().env
|
||||
const [enabled, siteKey, secretKey] = await Promise.all([
|
||||
env.SITE_CONFIG.get("TURNSTILE_ENABLED"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SITE_KEY"),
|
||||
env.SITE_CONFIG.get("TURNSTILE_SECRET_KEY"),
|
||||
])
|
||||
|
||||
return {
|
||||
enabled: enabled === "true",
|
||||
siteKey: siteKey || "",
|
||||
secretKey: secretKey || "",
|
||||
}
|
||||
}
|
||||
|
||||
export interface TurnstileVerificationResult {
|
||||
success: boolean
|
||||
reason?: "missing-token" | "verification-failed"
|
||||
}
|
||||
|
||||
export async function verifyTurnstileToken(token?: string | null): Promise<TurnstileVerificationResult> {
|
||||
const config = await getTurnstileConfig()
|
||||
|
||||
if (!config.enabled || !config.siteKey || !config.secretKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const trimmedToken = token?.trim()
|
||||
if (!trimmedToken) {
|
||||
return { success: false, reason: "missing-token" }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `secret=${encodeURIComponent(config.secretKey)}&response=${encodeURIComponent(trimmedToken)}`,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean }
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Turnstile verification error:", error)
|
||||
return { success: false, reason: "verification-failed" }
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export const authSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "用户名只能包含字母、数字、下划线和横杠")
|
||||
.refine(val => !val.includes('@'), "用户名不能是邮箱格式"),
|
||||
password: z.string()
|
||||
.min(8, "密码长度必须大于等于8位")
|
||||
.min(8, "密码长度必须大于等于8位"),
|
||||
turnstileToken: z.string().optional()
|
||||
})
|
||||
|
||||
export type AuthSchema = z.infer<typeof authSchema>
|
||||
export type AuthSchema = z.infer<typeof authSchema>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { NextResponse } from "next/server"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import { i18n, type Locale } from "@/i18n/config"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { Permission } from "@/lib/permissions"
|
||||
@@ -63,7 +63,9 @@ export async function middleware(request: Request) {
|
||||
const hasLocalePrefix = i18n.locales.includes(maybeLocale as any)
|
||||
if (!hasLocalePrefix) {
|
||||
const cookieLocale = request.headers.get('Cookie')?.match(/NEXT_LOCALE=([^;]+)/)?.[1]
|
||||
const targetLocale = (cookieLocale && i18n.locales.includes(cookieLocale as any)) ? cookieLocale : i18n.defaultLocale
|
||||
const acceptLanguage = request.headers.get('Accept-Language')
|
||||
const preferredLocale = resolvePreferredLocale(cookieLocale, acceptLanguage)
|
||||
const targetLocale = preferredLocale ?? i18n.defaultLocale
|
||||
const redirectURL = new URL(`/${targetLocale}${pathname}${url.search}`, request.url)
|
||||
return NextResponse.redirect(redirectURL)
|
||||
}
|
||||
@@ -71,6 +73,61 @@ export async function middleware(request: Request) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
function resolvePreferredLocale(cookieLocale: string | undefined, acceptLanguageHeader: string | null): Locale | null {
|
||||
if (cookieLocale && i18n.locales.includes(cookieLocale as Locale)) {
|
||||
return cookieLocale as Locale
|
||||
}
|
||||
|
||||
if (!acceptLanguageHeader) return null
|
||||
|
||||
const candidates = parseAcceptLanguage(acceptLanguageHeader)
|
||||
for (const lang of candidates) {
|
||||
const match = matchLocale(lang)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAcceptLanguage(header: string): string[] {
|
||||
return header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [lang, ...params] = part.trim().split(';')
|
||||
const qualityParam = params.find((param) => param.trim().startsWith('q='))
|
||||
const quality = qualityParam ? parseFloat(qualityParam.split('=')[1]) : 1
|
||||
return { lang: lang.toLowerCase(), quality: isNaN(quality) ? 1 : quality }
|
||||
})
|
||||
.sort((a, b) => b.quality - a.quality)
|
||||
.map((entry) => entry.lang)
|
||||
}
|
||||
|
||||
function matchLocale(lang: string): Locale | null {
|
||||
const exactMatch = i18n.locales.find((locale) => locale.toLowerCase() === lang)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const base = lang.split('-')[0]
|
||||
|
||||
// Handle Chinese variants with explicit regions or scripts
|
||||
if (base === 'zh') {
|
||||
if (lang.includes('tw') || lang.includes('hk') || lang.includes('mo') || lang.includes('hant')) {
|
||||
return 'zh-TW'
|
||||
}
|
||||
if (lang.includes('cn') || lang.includes('sg') || lang.includes('hans')) {
|
||||
return 'zh-CN'
|
||||
}
|
||||
// default Chinese fallback
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
const baseMatch = i18n.locales.find((locale) => locale.toLowerCase().split('-')[0] === base)
|
||||
if (baseMatch) return baseMatch
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|.*\\..*).*)', // all pages excluding static assets
|
||||
@@ -80,4 +137,4 @@ export const config = {
|
||||
'/api/config/:path*',
|
||||
'/api/api-keys/:path*',
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ const nextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.googleusercontent.com',
|
||||
}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -117,11 +117,11 @@ const updateDatabaseConfig = (dbId: string) => {
|
||||
"wrangler.email.json",
|
||||
"wrangler.cleanup.json",
|
||||
];
|
||||
|
||||
|
||||
for (const filename of configFiles) {
|
||||
const configPath = resolve(filename);
|
||||
if (!existsSync(configPath)) continue;
|
||||
|
||||
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
if (json.d1_databases && json.d1_databases.length > 0) {
|
||||
@@ -140,7 +140,7 @@ const updateDatabaseConfig = (dbId: string) => {
|
||||
*/
|
||||
const updateKVConfig = (namespaceId: string) => {
|
||||
console.log(`📝 Updating KV namespace ID (${namespaceId}) in configurations...`);
|
||||
|
||||
|
||||
// KV命名空间只在主wrangler.json中使用
|
||||
const wranglerPath = resolve("wrangler.json");
|
||||
if (existsSync(wranglerPath)) {
|
||||
@@ -165,11 +165,11 @@ const checkAndCreateDatabase = async () => {
|
||||
|
||||
try {
|
||||
const database = await getDatabase();
|
||||
|
||||
|
||||
if (!database || !database.uuid) {
|
||||
throw new Error('Database object is missing a valid UUID');
|
||||
}
|
||||
|
||||
|
||||
updateDatabaseConfig(database.uuid);
|
||||
console.log(`✅ Database "${DATABASE_NAME}" already exists (ID: ${database.uuid})`);
|
||||
} catch (error) {
|
||||
@@ -177,11 +177,11 @@ const checkAndCreateDatabase = async () => {
|
||||
console.log(`⚠️ Database not found, creating new database...`);
|
||||
try {
|
||||
const database = await createDatabase();
|
||||
|
||||
|
||||
if (!database || !database.uuid) {
|
||||
throw new Error('Database object is missing a valid UUID');
|
||||
}
|
||||
|
||||
|
||||
updateDatabaseConfig(database.uuid);
|
||||
console.log(`✅ Database "${DATABASE_NAME}" created successfully (ID: ${database.uuid})`);
|
||||
} catch (createError) {
|
||||
@@ -259,7 +259,7 @@ const checkAndCreatePages = async () => {
|
||||
if (!CUSTOM_DOMAIN && pages.subdomain) {
|
||||
console.log("⚠️ CUSTOM_DOMAIN is empty, using pages default domain...");
|
||||
console.log("📝 Updating environment variables...");
|
||||
|
||||
|
||||
// 更新环境变量为默认的Pages域名
|
||||
const appUrl = `https://${pages.subdomain}`;
|
||||
updateEnvVar("CUSTOM_DOMAIN", appUrl);
|
||||
@@ -278,26 +278,18 @@ const pushPagesSecret = () => {
|
||||
console.log("🔐 Pushing environment secrets to Pages...");
|
||||
|
||||
// 定义运行时所需的环境变量列表
|
||||
const runtimeEnvVars = ['AUTH_GITHUB_ID', 'AUTH_GITHUB_SECRET', 'AUTH_SECRET'];
|
||||
const runtimeEnvVars = ['AUTH_GITHUB_ID', 'AUTH_GITHUB_SECRET', 'AUTH_GOOGLE_ID', 'AUTH_GOOGLE_SECRET', 'AUTH_SECRET'];
|
||||
|
||||
// 兼容老的部署方式,如果这些环境变量不存在,则说明是老的部署方式,跳过推送
|
||||
for (const varName of runtimeEnvVars) {
|
||||
if (!process.env[varName]) {
|
||||
console.log(`🔐 Skipping pushing secrets to Pages...`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保.env文件存在
|
||||
if (!existsSync(resolve('.env'))) {
|
||||
setupEnvFile();
|
||||
}
|
||||
|
||||
|
||||
// 创建一个临时文件,只包含运行时所需的环境变量
|
||||
const envContent = readFileSync(resolve('.env'), 'utf-8');
|
||||
const runtimeEnvFile = resolve('.env.runtime');
|
||||
|
||||
|
||||
// 从.env文件中提取运行时变量
|
||||
const runtimeEnvContent = envContent
|
||||
.split('\n')
|
||||
@@ -305,26 +297,27 @@ const pushPagesSecret = () => {
|
||||
const trimmedLine = line.trim();
|
||||
// 跳过注释和空行
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) return false;
|
||||
|
||||
|
||||
// 检查是否为运行时所需的环境变量
|
||||
for (const varName of runtimeEnvVars) {
|
||||
if (line.startsWith(`${varName} =`) || line.startsWith(`${varName}=`)) {
|
||||
return true;
|
||||
const value = line.substring(line.indexOf('=') + 1).trim().replace(/^["']|["']$/g, '');
|
||||
return value.length > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
|
||||
// 写入临时文件
|
||||
writeFileSync(runtimeEnvFile, runtimeEnvContent);
|
||||
|
||||
|
||||
// 使用临时文件推送secrets
|
||||
execSync(`pnpm dlx wrangler pages secret bulk ${runtimeEnvFile}`, { stdio: "inherit" });
|
||||
|
||||
|
||||
// 清理临时文件
|
||||
execSync(`rm ${runtimeEnvFile}`, { stdio: "inherit" });
|
||||
|
||||
|
||||
console.log("✅ Secrets pushed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push secrets:", error);
|
||||
@@ -381,14 +374,14 @@ const setupEnvFile = () => {
|
||||
console.log("📄 Setting up environment file...");
|
||||
const envFilePath = resolve(".env");
|
||||
const envExamplePath = resolve(".env.example");
|
||||
|
||||
|
||||
// 如果.env文件不存在,则从.env.example复制创建
|
||||
if (!existsSync(envFilePath) && existsSync(envExamplePath)) {
|
||||
console.log("⚠️ .env file does not exist, creating from example...");
|
||||
|
||||
|
||||
// 从示例文件复制
|
||||
let envContent = readFileSync(envExamplePath, "utf-8");
|
||||
|
||||
|
||||
// 填充当前的环境变量
|
||||
const envVarMatches = envContent.match(/^([A-Z_]+)\s*=\s*".*?"/gm);
|
||||
if (envVarMatches) {
|
||||
@@ -400,7 +393,7 @@ const setupEnvFile = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
writeFileSync(envFilePath, envContent);
|
||||
console.log("✅ .env file created from example");
|
||||
} else if (existsSync(envFilePath)) {
|
||||
@@ -417,22 +410,22 @@ const setupEnvFile = () => {
|
||||
const updateEnvVar = (name: string, value: string) => {
|
||||
// 首先更新进程环境变量
|
||||
process.env[name] = value;
|
||||
|
||||
|
||||
// 然后尝试更新.env文件
|
||||
const envFilePath = resolve(".env");
|
||||
if (!existsSync(envFilePath)) {
|
||||
setupEnvFile();
|
||||
}
|
||||
|
||||
|
||||
let envContent = readFileSync(envFilePath, "utf-8");
|
||||
const regex = new RegExp(`^${name}\\s*=\\s*".*?"`, "m");
|
||||
|
||||
|
||||
if (envContent.match(regex)) {
|
||||
envContent = envContent.replace(regex, `${name} = "${value}"`);
|
||||
} else {
|
||||
envContent += `\n${name} = "${value}"`;
|
||||
}
|
||||
|
||||
|
||||
writeFileSync(envFilePath, envContent);
|
||||
console.log(`✅ Updated ${name} in .env file`);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,9 +23,19 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./app/*"]
|
||||
"@/*": [
|
||||
"./app/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"docs"
|
||||
]
|
||||
}
|
||||
13
types.d.ts
vendored
13
types.d.ts
vendored
@@ -7,6 +7,14 @@ declare global {
|
||||
SITE_CONFIG: KVNamespace;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (element: HTMLElement | string, options: Record<string, unknown>) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
type Env = CloudflareEnv
|
||||
}
|
||||
|
||||
@@ -14,11 +22,12 @@ declare module "next-auth" {
|
||||
interface User {
|
||||
roles?: { name: string }[]
|
||||
username?: string | null
|
||||
providers?: string[]
|
||||
}
|
||||
|
||||
|
||||
interface Session {
|
||||
user: User
|
||||
}
|
||||
}
|
||||
|
||||
export type { Env }
|
||||
export type { Env }
|
||||
|
||||
Reference in New Issue
Block a user