Compare commits
4 Commits
436d58be52
...
0cb240791b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb240791b | |||
| d9fd09c13d | |||
| 5406543cbe | |||
| d2a3d32376 |
@@ -2,6 +2,8 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: learn-languages
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
|
||||
@@ -13,3 +13,11 @@ DATABASE_URL=
|
||||
|
||||
// DashScore
|
||||
DASHSCORE_API_KEY=
|
||||
|
||||
// SMTP Email - Resend (https://resend.com)
|
||||
SMTP_HOST=smtp.resend.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=resend
|
||||
SMTP_PASS=re_your_resend_api_key
|
||||
SMTP_FROM=onboarding@resend.dev
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 23+
|
||||
- Node.js 24+
|
||||
- PostgreSQL 14+
|
||||
- pnpm 8+ (推荐) 或 npm/yarn
|
||||
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"signUpFailed": "Registrierung fehlgeschlagen",
|
||||
"fillAllFields": "Bitte füllen Sie alle Felder aus",
|
||||
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein"
|
||||
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
|
||||
"forgotPassword": "Passwort vergessen",
|
||||
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
|
||||
"sendResetEmail": "Reset-E-Mail senden",
|
||||
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
|
||||
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
|
||||
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
|
||||
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"newPassword": "Neues Passwort",
|
||||
"invalidToken": "Ungültiger oder abgelaufener Link",
|
||||
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
|
||||
"requestNewToken": "Neuen Reset-Link anfordern",
|
||||
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "Login failed",
|
||||
"signUpFailed": "Sign up failed",
|
||||
"fillAllFields": "Please fill in all fields",
|
||||
"enterCredentials": "Please enter username and password"
|
||||
"enterCredentials": "Please enter username and password",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
|
||||
"sendResetEmail": "Send Reset Email",
|
||||
"resetPasswordFailed": "Failed to send reset email",
|
||||
"resetPasswordEmailSent": "Reset email sent successfully",
|
||||
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
|
||||
"checkYourEmail": "Check Your Email",
|
||||
"backToLogin": "Back to Login",
|
||||
"resetPassword": "Reset Password",
|
||||
"newPassword": "New Password",
|
||||
"invalidToken": "Invalid or Expired Link",
|
||||
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
|
||||
"requestNewToken": "Request New Reset Link",
|
||||
"resetPasswordSuccess": "Password reset successfully",
|
||||
"resetPasswordSuccessTitle": "Password Reset Complete",
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"signUpFailed": "Échec de l'inscription",
|
||||
"fillAllFields": "Veuillez remplir tous les champs",
|
||||
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe"
|
||||
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
|
||||
"forgotPassword": "Mot de passe oublié",
|
||||
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
|
||||
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
|
||||
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
|
||||
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
|
||||
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
|
||||
"checkYourEmail": "Vérifiez votre e-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"invalidToken": "Lien invalide ou expiré",
|
||||
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
|
||||
"requestNewToken": "Demander un nouveau lien de réinitialisation",
|
||||
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
|
||||
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "Accesso fallito",
|
||||
"signUpFailed": "Registrazione fallita",
|
||||
"fillAllFields": "Per favore compila tutti i campi",
|
||||
"enterCredentials": "Per favore inserisci nome utente e password"
|
||||
"enterCredentials": "Per favore inserisci nome utente e password",
|
||||
"forgotPassword": "Password Dimenticata",
|
||||
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
|
||||
"sendResetEmail": "Invia Email di Reset",
|
||||
"resetPasswordFailed": "Impossibile inviare email di reset",
|
||||
"resetPasswordEmailSent": "Email di reset inviata con successo",
|
||||
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||
"checkYourEmail": "Controlla la tua Email",
|
||||
"backToLogin": "Torna al Login",
|
||||
"resetPassword": "Reimposta Password",
|
||||
"newPassword": "Nuova Password",
|
||||
"invalidToken": "Link Non Valido o Scaduto",
|
||||
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
|
||||
"requestNewToken": "Richiedi Nuovo Link di Reset",
|
||||
"resetPasswordSuccess": "Password reimpostata con successo",
|
||||
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"signUpFailed": "新規登録に失敗しました",
|
||||
"fillAllFields": "すべてのフィールドに入力してください",
|
||||
"enterCredentials": "ユーザー名とパスワードを入力してください"
|
||||
"enterCredentials": "ユーザー名とパスワードを入力してください",
|
||||
"forgotPassword": "パスワードをお忘れですか",
|
||||
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
|
||||
"sendResetEmail": "リセットメールを送信",
|
||||
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||
"checkYourEmail": "メールをご確認ください",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"resetPassword": "パスワードをリセット",
|
||||
"newPassword": "新しいパスワード",
|
||||
"invalidToken": "無効または期限切れのリンク",
|
||||
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
|
||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "로그인 실패",
|
||||
"signUpFailed": "회원가입 실패",
|
||||
"fillAllFields": "모든 필드를 입력하세요",
|
||||
"enterCredentials": "사용자명과 비밀번호를 입력하세요"
|
||||
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
|
||||
"forgotPassword": "비밀번호 찾기",
|
||||
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
|
||||
"sendResetEmail": "재설정 이메일 보내기",
|
||||
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||
"checkYourEmail": "이메일을 확인하세요",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"resetPassword": "비밀번호 재설정",
|
||||
"newPassword": "새 비밀번호",
|
||||
"invalidToken": "유효하지 않거나 만료된 링크",
|
||||
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
|
||||
"requestNewToken": "새 재설정 링크 요청",
|
||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "كىرىش مەغلۇپ بولدى",
|
||||
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
|
||||
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
|
||||
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ"
|
||||
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
|
||||
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
|
||||
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
|
||||
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
|
||||
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||
"backToLogin": "كىرىشكە قايتىش",
|
||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||
"newPassword": "يېڭى پارول",
|
||||
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
|
||||
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
|
||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -150,7 +150,23 @@
|
||||
"loginFailed": "登录失败",
|
||||
"signUpFailed": "注册失败",
|
||||
"fillAllFields": "请填写所有字段",
|
||||
"enterCredentials": "请输入用户名和密码"
|
||||
"enterCredentials": "请输入用户名和密码",
|
||||
"forgotPassword": "忘记密码",
|
||||
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
|
||||
"sendResetEmail": "发送重置邮件",
|
||||
"resetPasswordFailed": "发送重置邮件失败",
|
||||
"resetPasswordEmailSent": "重置邮件已发送",
|
||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||
"checkYourEmail": "请查收邮件",
|
||||
"backToLogin": "返回登录",
|
||||
"resetPassword": "重置密码",
|
||||
"newPassword": "新密码",
|
||||
"invalidToken": "链接无效或已过期",
|
||||
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
|
||||
"requestNewToken": "重新申请重置链接",
|
||||
"resetPasswordSuccess": "密码重置成功",
|
||||
"resetPasswordSuccessTitle": "密码重置完成",
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -42,6 +42,9 @@ importers:
|
||||
next-intl:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
nodemailer:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.16.3
|
||||
@@ -82,6 +85,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.0.3
|
||||
version: 25.0.3
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.11
|
||||
version: 7.0.11
|
||||
'@types/react':
|
||||
specifier: 19.2.7
|
||||
version: 19.2.7
|
||||
@@ -1049,6 +1055,9 @@ packages:
|
||||
'@types/node@25.0.3':
|
||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
|
||||
|
||||
'@types/pg@8.15.6':
|
||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||
|
||||
@@ -2661,6 +2670,10 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
nodemailer@8.0.2:
|
||||
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4393,6 +4406,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@types/pg@8.15.6':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
@@ -6068,6 +6085,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nodemailer@8.0.2: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nypm@0.6.2:
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"locale1" VARCHAR(10) NOT NULL,
|
||||
"locale2" VARCHAR(10) NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
-- 重命名并修改类型为 TEXT
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale1" TO "language1";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale2" TO "language2";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_lookups" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"text" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dictionary_word_id" INTEGER,
|
||||
"dictionary_phrase_id" INTEGER,
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_words" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrases" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_word_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"word_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrase_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"phrase_id" INTEGER NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
||||
@@ -1,30 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" VARCHAR(20) NOT NULL,
|
||||
"target_language" VARCHAR(20) NOT NULL,
|
||||
"translated_text" TEXT NOT NULL,
|
||||
"source_ipa" TEXT,
|
||||
"target_ipa" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
@@ -1,7 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||
DROP COLUMN "dictionary_word_id",
|
||||
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrase_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrases";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_word_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_words";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_items" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"item_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
||||
ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
@@ -1,33 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "folders" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folder_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
@@ -0,0 +1,262 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"displayUsername" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"language1" TEXT NOT NULL,
|
||||
"language2" TEXT NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folder_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_lookups" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"text" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dictionary_item_id" INTEGER,
|
||||
"normalized_text" TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_items" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"item_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" TEXT NOT NULL,
|
||||
"target_language" TEXT NOT NULL,
|
||||
"translated_text" TEXT NOT NULL,
|
||||
"source_ipa" TEXT,
|
||||
"target_ipa" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -16,7 +16,7 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String? @unique
|
||||
username String @unique
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
|
||||
102
src/app/(auth)/forgot-password/page.tsx
Normal file
102
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleResetRequest = async () => {
|
||||
if (!email) {
|
||||
toast.error(t("emailRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSent(true);
|
||||
toast.success(t("resetPasswordEmailSent"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("checkYourEmail")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordEmailSentHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("forgotPassword")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
{t("forgotPasswordHint")}
|
||||
</p>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetRequest}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("sendResetEmail")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,13 @@ export default function LoginPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-gray-500 hover:text-primary-500 self-end"
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleLogin}
|
||||
|
||||
154
src/app/(auth)/reset-password/page.tsx
Normal file
154
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
toast.error(t("fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t("passwordsNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error(t("passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
toast.error(t("invalidToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.resetPassword({
|
||||
newPassword: password,
|
||||
token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSuccess(true);
|
||||
toast.success(t("resetPasswordSuccess"));
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 2000);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("resetPasswordSuccessTitle")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordSuccessHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("invalidToken")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("invalidTokenHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("requestNewToken")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("resetPassword")}
|
||||
</h1>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("newPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("confirmPassword")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetPassword}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("resetPassword")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/auth.ts
62
src/auth.ts
@@ -1,21 +1,57 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { prisma } from "./lib/db";
|
||||
import { username } from "better-auth/plugins";
|
||||
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||
import { prisma } from "./lib/db";
|
||||
import {
|
||||
sendEmail,
|
||||
generateVerificationEmailHtml,
|
||||
generateResetPasswordEmailHtml,
|
||||
} from "./lib/email";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql"
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "重置您的密码 - Learn Languages",
|
||||
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "验证您的邮箱 - Learn Languages",
|
||||
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies(), username()],
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
|
||||
|
||||
const body = ctx.body as { username?: string };
|
||||
if (!body.username || body.username.trim() === "") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Username is required",
|
||||
});
|
||||
}
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies(), username()]
|
||||
},
|
||||
});
|
||||
|
||||
104
src/lib/email.ts
Normal file
104
src/lib/email.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("email");
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, html, text }: SendEmailOptions) {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
log.info("Email sent", { to, subject, messageId: info.messageId });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
log.error("Failed to send email", { to, subject, error });
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function generateVerificationEmailHtml(url: string, userName: string) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>验证您的邮箱地址</h1>
|
||||
<p>您好,${userName}!</p>
|
||||
<p>感谢您注册。请点击下方按钮验证您的邮箱地址:</p>
|
||||
<p>
|
||||
<a href="${url}" class="button">验证邮箱</a>
|
||||
</p>
|
||||
<p>或者复制以下链接到浏览器:</p>
|
||||
<p style="word-break: break-all; color: #666;">${url}</p>
|
||||
<p>此链接将在 24 小时后过期。</p>
|
||||
<div class="footer">
|
||||
<p>如果您没有注册此账户,请忽略此邮件。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateResetPasswordEmailHtml(url: string, userName: string) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>重置您的密码</h1>
|
||||
<p>您好,${userName}!</p>
|
||||
<p>我们收到了重置您账户密码的请求。请点击下方按钮设置新密码:</p>
|
||||
<p>
|
||||
<a href="${url}" class="button">重置密码</a>
|
||||
</p>
|
||||
<p>或者复制以下链接到浏览器:</p>
|
||||
<p style="word-break: break-all; color: #666;">${url}</p>
|
||||
<p>此链接将在 1 小时后过期。</p>
|
||||
<div class="footer">
|
||||
<p>如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user