diff --git a/.env.example b/.env.example index a5ea2e5..6ad640b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ HOST=localhost LOG_LEVEL=info APP_KEY= NODE_ENV=development + DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=root @@ -11,10 +12,15 @@ DB_PASSWORD=root DB_DATABASE=app LIMITER_STORE=redis DRIVE_DISK=fs -SMTP_HOST= -SMTP_PORT= + REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= + MAILGUN_API_KEY= -MAILGUN_DOMAIN= \ No newline at end of file +MAILGUN_DOMAIN= + +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET= + +PUBLIC_AUTH_URL= \ No newline at end of file diff --git a/adonisrc.ts b/adonisrc.ts index f8f724a..3ad708c 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -50,7 +50,11 @@ export default defineConfig({ () => import('@adonisjs/cache/cache_provider'), () => import('@adonisjs/drive/drive_provider'), () => import('@adonisjs/mail/mail_provider'), - () => import('@adonisjs/redis/redis_provider') + () => import('@adonisjs/redis/redis_provider'), + () => import('@adonisjs/transmit/transmit_provider'), + () => import('@adonisjs/core/providers/vinejs_provider'), + () => import('adonis-captcha-guard/providers/captcha_provider'), + () => import('@adonisjs/core/providers/edge_provider') ], /* @@ -87,4 +91,8 @@ export default defineConfig({ ], forceExit: false, }, + metaFiles: [{ + pattern: 'resources/views/**/*.edge', + reloadServer: false, + }] }) diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts new file mode 100644 index 0000000..2f566b5 --- /dev/null +++ b/app/controllers/auth_controller.ts @@ -0,0 +1,76 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { requestLoginValidator, verifyCodeValidator } from '#validators/auth' +import mail from '@adonisjs/mail/services/main' +import { AuthService } from '#services/auth_service' +import { inject } from '@adonisjs/core' +import app from '@adonisjs/core/services/app' +import env from '#start/env' + +@inject() +export default class AuthController { + constructor(private authService: AuthService) {} + + // POST /auth/request + async requestLogin({ request, response, captcha }: HttpContext) { + // Validate captcha + if (app.inProduction) { + const validateResult = await (captcha.use('turnstile') as any).validate() + if (!validateResult.success) { + return response.badRequest({ + message: 'Captcha validation failed', + error: validateResult.errorCodes, + }) + } + } + + // Generate token + const expiresIn = '15 minutes' + const { email } = await request.validateUsing(requestLoginValidator) + const payload = await this.authService.generateToken(email, expiresIn) + + // Send email + await mail.send((message) => { + message + .from(env.get('MAIL_FROM')!) + .to(email) + .subject(payload.emailTitle) + .htmlView('mails/auth', payload) + .textView('mails/auth-fallback', payload) + }).then(console.log).catch(console.error) + + return { + success: true, + data: { + token: payload.token, + }, + } + } + + // POST /auth/verify + async verifyCode({ request }: HttpContext) { + // Validate code + const { code } = await request.validateUsing(verifyCodeValidator) + const email = await this.authService.validateCode(code) + if (!email) { + return { + success: false, + message: 'Le code est invalide ou a expiré', + } + } + + // TOOD: Login + // Find user by email (string similary) + } + + magicLink({}: HttpContext) { + // Validate signed url (adonis) + // + login current device + // + SSE to notify other devices (and login) + } + + listen({}: HttpContext) { + // Listen for SSE events + // Need an AUTH token to connect + // AUTH token sent to client in requestLogin + } +} diff --git a/app/exceptions/handler.ts b/app/exceptions/handler.ts index 779aea3..724380d 100644 --- a/app/exceptions/handler.ts +++ b/app/exceptions/handler.ts @@ -1,4 +1,3 @@ -import app from '@adonisjs/core/services/app' import { HttpContext, ExceptionHandler } from '@adonisjs/core/http' export default class HttpExceptionHandler extends ExceptionHandler { @@ -6,21 +5,29 @@ export default class HttpExceptionHandler extends ExceptionHandler { * In debug mode, the exception handler will display verbose errors * with pretty printed stack traces. */ - protected debug = !app.inProduction + protected debug = false /** * Status pages are used to display a custom HTML pages for certain error * codes. You might want to enable them in production only, but feel * free to enable them in development as well. */ - protected renderStatusPages = app.inProduction + protected renderStatusPages = false /** * The method is used for handling errors and returning * response to the client */ - async handle(error: unknown, ctx: HttpContext) { - return super.handle(error, ctx) + async handle(error: any, ctx: HttpContext) { + const statusCode = error.status || error.statusCode || 500 + const message = error.message || 'Internal Server Error' + const stack = this.debug ? error.stack : undefined + const response = { + status: statusCode, + error: message, + stack, + } + return ctx.response.status(statusCode).json(response) } /** diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts new file mode 100644 index 0000000..7a7c9fd --- /dev/null +++ b/app/services/auth_service.ts @@ -0,0 +1,43 @@ +import encryption from '@adonisjs/core/services/encryption' +import cache from '@adonisjs/cache/services/main' +import env from '#start/env' + +export class AuthService { + async generateToken(email: string, expiresIn: string) { + // Generate magic link token + const token = encryption.encrypt(email, expiresIn) + const magicLink = env.get('PUBLIC_AUTH_URL') + '/success?signature=' + token + + // Generate code + const formattedOTP = Math.floor(Math.random() * 1000000) + .toString() + .padStart(6, '0') + await cache.set({ + key: 'auth:otp:' + formattedOTP, + value: email, + ttl: expiresIn, + }) + + const emailTitle = `${formattedOTP} est votre code de vérification pour ${env.get('APP_NAME')}` + return { + emailTitle, + formattedOTP, + magicLink, + expiresIn, + email, + token, + } + } + + async validateCode(code: string) { + // Validate code + const key = 'auth:otp:' + code + const email = await cache.get({ key }) + if (!email) return false + + // Delete code from cache + await cache.delete({ key }) + + return email + } +} diff --git a/app/validators/auth.ts b/app/validators/auth.ts new file mode 100644 index 0000000..6c19250 --- /dev/null +++ b/app/validators/auth.ts @@ -0,0 +1,38 @@ +import vine from '@vinejs/vine' + +export const requestLoginValidator = vine.compile( + vine.object({ + email: vine + .string() + .email({ + host_whitelist: ['bginette.fr'], + }) + .normalizeEmail({ + all_lowercase: true, + }), + }) +) + +export const verifyCodeValidator = vine.compile( + vine.object({ + code: vine.string().minLength(6).maxLength(6), + }) +) + +export const magicLinkValidator = vine.compile( + vine.object({ + token: vine.string(), + }) +) + +export const listenValidator = vine.compile( + vine.object({ + token: vine.string().uuid(), + }) +) + +export const exchangeTokenValidator = vine.compile( + vine.object({ + token: vine.string().uuid(), + }) +) diff --git a/config/captcha.ts b/config/captcha.ts new file mode 100644 index 0000000..8d45f19 --- /dev/null +++ b/config/captcha.ts @@ -0,0 +1,19 @@ +import env from '#start/env' +import { defineConfig, services } from 'adonis-captcha-guard' + +const captchaConfig = defineConfig({ + turnstile: services.turnstile({ + siteKey: env.get('TURNSTILE_SITE_KEY')!, + secret: env.get('TURNSTILE_SECRET')!, + }), + recaptcha: services.recaptcha({ + siteKey: env.get('RECAPTCHA_SITE_KEY')!, + secret: env.get('RECAPTCHA_SECRET')!, + }), +}) + +export default captchaConfig + +declare module '@adonisjs/core/types' { + interface CaptchaProviders {} +} diff --git a/config/transmit.ts b/config/transmit.ts new file mode 100644 index 0000000..b27b5f0 --- /dev/null +++ b/config/transmit.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@adonisjs/transmit' + +export default defineConfig({ + pingInterval: false, + transport: null, +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e0e1611 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + redis: + image: redis + container_name: khollise-redis + restart: unless-stopped + ports: + - "6379:6379" diff --git a/package-lock.json b/package-lock.json index 21721b8..33ae3ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,10 @@ "@adonisjs/lucid": "^21.6.1", "@adonisjs/mail": "^9.2.2", "@adonisjs/redis": "^9.2.0", + "@adonisjs/transmit": "^2.0.2", + "@vinejs/vine": "^3.0.1", + "adonis-captcha-guard": "^1.0.1", + "edge.js": "^6.2.1", "luxon": "^3.6.1", "pg": "^8.16.0", "reflect-metadata": "^0.2.2" @@ -694,6 +698,21 @@ "node": ">=18.16.0" } }, + "node_modules/@adonisjs/transmit": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@adonisjs/transmit/-/transmit-2.0.2.tgz", + "integrity": "sha512-Oyh4S1773N1Ams9mmFM2pplLx8R0IdYwqSTBLRgeDWaR+cyWrL9ZQvF2q7GqIjNzZsXuDgMdPuLlNo+69zIZIg==", + "license": "MIT", + "dependencies": { + "@boringnode/transmit": "^0.2.1" + }, + "engines": { + "node": ">=20.11.1" + }, + "peerDependencies": { + "@adonisjs/core": "^6.2.0" + } + }, "node_modules/@adonisjs/tsconfig": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@adonisjs/tsconfig/-/tsconfig-1.4.0.tgz", @@ -770,6 +789,21 @@ } } }, + "node_modules/@boringnode/transmit": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@boringnode/transmit/-/transmit-0.2.2.tgz", + "integrity": "sha512-xIBg5PKqqgawNsXffq1P+BpRDjplfwOspwcFH5wfSN6uVcd5hYGC9lJpcXa1oL3wkQZimtiJhrTHhZZtgh2lqA==", + "license": "MIT", + "dependencies": { + "@boringnode/bus": "^0.7.0", + "@poppinss/utils": "^6.8.3", + "emittery": "^1.0.3", + "matchit": "^1.1.0" + }, + "engines": { + "node": ">=20.11.1" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", @@ -1461,7 +1495,6 @@ "resolved": "https://registry.npmjs.org/@poppinss/inspect/-/inspect-1.0.1.tgz", "integrity": "sha512-kLeEaBSGhlleyYvKc7c9s3uE6xv7cwyulE0EgHf4jU/CL96h0yC4mkdw1wvC1l1PYYQozCGy46FwMBAAMOobCA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2027,6 +2060,12 @@ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.32.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", @@ -2217,6 +2256,34 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", + "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", + "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "license": "MIT", + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@types/validator": "^13.12.2", + "@vinejs/compiler": "^3.0.0", + "camelcase": "^8.0.0", + "dayjs": "^1.11.13", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2292,6 +2359,22 @@ "node": ">=0.4.0" } }, + "node_modules/adonis-captcha-guard": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/adonis-captcha-guard/-/adonis-captcha-guard-1.0.1.tgz", + "integrity": "sha512-+KkQeE1sNEgdfcX+hR+7pzl9Hib1u3XOdOHJ4PEYpYsLc+qD87LaLg8mDElDUSxAoH/Z5AlSeTr0Gsq5n5VXQQ==", + "license": "MIT", + "dependencies": { + "@poppinss/utils": "^6.7.3", + "got": "^14.2.1" + }, + "engines": { + "node": ">=20.6.0" + }, + "peerDependencies": { + "@adonisjs/core": "^6.2.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2396,7 +2479,6 @@ "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", "license": "MIT", - "peer": true, "bin": { "astring": "bin/astring" } @@ -2655,6 +2737,18 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001718", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", @@ -2804,8 +2898,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/clean-regexp": { "version": "1.0.0", @@ -3124,6 +3217,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3266,6 +3365,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -3297,7 +3402,6 @@ "resolved": "https://registry.npmjs.org/edge-error/-/edge-error-4.0.2.tgz", "integrity": "sha512-jB76VYn8wapDHKHSOmP3vbKLoa77RJYsTLNmfl8+cuCD69uxZtP3h+kqV+Prw/YkYmN7yHyp4IApE15pDByk0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.16.0" } @@ -3307,7 +3411,6 @@ "resolved": "https://registry.npmjs.org/edge-lexer/-/edge-lexer-6.0.3.tgz", "integrity": "sha512-/s15CNnfhZv97bsW+ZgV5rtONULYjhCDYu+usbVLqZ8UQ6b/hQUNvQSIQBXA6Gql9dm72TMBB9sb/eWM2esufg==", "license": "MIT", - "peer": true, "dependencies": { "edge-error": "^4.0.2" }, @@ -3320,7 +3423,6 @@ "resolved": "https://registry.npmjs.org/edge-parser/-/edge-parser-9.0.4.tgz", "integrity": "sha512-vnjzfpqpjM4Mjt9typc1zLoFpC1F6kAObfcdyA6rSy+izIPji2RaQz5jWx5s5iG9hNcuyjtNyGRCLFVfoYhWcA==", "license": "MIT", - "peer": true, "dependencies": { "acorn": "^8.14.0", "astring": "^1.9.0", @@ -3337,7 +3439,6 @@ "resolved": "https://registry.npmjs.org/edge.js/-/edge.js-6.2.1.tgz", "integrity": "sha512-me875zh6YA0V429hywgQIpHgMvQkondv5XHaP6EsL2yIBpLcBWCl7Ba1cai0SwYhp8iD0IyV3KjpxLrnW7S2Ag==", "license": "MIT", - "peer": true, "dependencies": { "@poppinss/inspect": "^1.0.1", "@poppinss/macroable": "^1.0.4", @@ -3510,7 +3611,6 @@ "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4174,8 +4274,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/function-bind": { "version": "1.1.2", @@ -4896,8 +4995,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -5300,6 +5398,18 @@ "dev": true, "license": "ISC" }, + "node_modules/matchit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/matchit/-/matchit-1.1.0.tgz", + "integrity": "sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==", + "license": "MIT", + "dependencies": { + "@arr/every": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6351,7 +6461,6 @@ "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -7317,7 +7426,6 @@ "resolved": "https://registry.npmjs.org/stringify-attributes/-/stringify-attributes-4.0.0.tgz", "integrity": "sha512-6Hq3K153wTTfhEHb4V/viuqmb0DRn08JCrRnmqc4Q/tmoNuvd4DEyqkiiJXtvVz8ZSUhlCQr7zCpCVTgrelesg==", "license": "MIT", - "peer": true, "dependencies": { "escape-goat": "^4.0.0" }, @@ -7835,6 +7943,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 84553c0..0d574a8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,10 @@ "@adonisjs/lucid": "^21.6.1", "@adonisjs/mail": "^9.2.2", "@adonisjs/redis": "^9.2.0", + "@adonisjs/transmit": "^2.0.2", + "@vinejs/vine": "^3.0.1", + "adonis-captcha-guard": "^1.0.1", + "edge.js": "^6.2.1", "luxon": "^3.6.1", "pg": "^8.16.0", "reflect-metadata": "^0.2.2" diff --git a/resources/views/mails/auth-fallback.edge b/resources/views/mails/auth-fallback.edge new file mode 100644 index 0000000..555ca5a --- /dev/null +++ b/resources/views/mails/auth-fallback.edge @@ -0,0 +1,19 @@ +Bonjour, + +Nous avons reçu une demande de connexion à votre compte Khollisé. + +Cliquez sur ce lien pour vous connecter : +{{ magicLink }} + +Vous pouvez également entrer ce code de vérification à 6 chiffres : +{{ formattedOTP }} + +Ce lien et ce code expireront dans {{ expiresIn }} et ne peuvent être utilisés qu'une seule fois. + +Si vous n'avez pas demandé cette connexion, vous pouvez ignorer cet e-mail. + +--- + +Cet e-mail a été envoyé à {{ email }} + +© {{ new Date().getFullYear() }} Khollisé. Tous droits réservés. diff --git a/resources/views/mails/auth.edge b/resources/views/mails/auth.edge new file mode 100644 index 0000000..7b60ad5 --- /dev/null +++ b/resources/views/mails/auth.edge @@ -0,0 +1,48 @@ + + +
+ + +