Email Auth per Mailbox implementation - full multi user env

This commit is contained in:
ChrispyBacon-dev 2026-04-12 08:37:43 +02:00
parent 599636ec0a
commit 513143a511
17 changed files with 401 additions and 68 deletions

View file

@ -47,7 +47,7 @@ oauth = None
limiter = Limiter(
key_func=get_remote_address,
default_limits=[],
storage_uri="memory://"
storage_uri=os.environ.get('REDIS_URL', 'memory://')
)
class QueueLogHandler(logging.Handler):

View file

@ -2063,6 +2063,35 @@ async function emailRepairDns(domain) {
}
}
async function emailSetPassword(address, domain) {
const password = prompt(`New password for ${address} (min 8 characters):`);
if (!password) return;
if (password.length < 8) {
await dfAlert('Password must be at least 8 characters.', 'Error');
return;
}
const confirmed = prompt('Confirm password:');
if (password !== confirmed) {
await dfAlert('Passwords do not match.', 'Error');
return;
}
try {
const response = await fetch('/email/mailbox/set-password', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address, domain, password })
});
const data = await response.json();
if (data.success) {
await dfAlert(`Password set for ${address}.`, 'Success');
} else {
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
}
} catch (e) {
console.error(e);
}
}
async function emailRedeployWorkers() {
if (!confirm('Redeploy all inbound and outbound workers? This will push the latest worker code and bindings to Cloudflare.')) return;
try {

View file

@ -111,6 +111,7 @@
<td>{{ mb.display_name }}</td>
<td>{{ domain }}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="emailSetPassword('{{ addr }}', '{{ domain }}')">Set Password</button>
<button class="btn btn-sm btn-error" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}')">{{ t('email.delete') }}</button>
</td>
</tr>

View file

@ -10,7 +10,8 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo
from flask_login import login_required, current_user
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from app import config, docker_client
from werkzeug.security import generate_password_hash, check_password_hash
from app import config, docker_client, limiter
from app.core import email_manager
from app.core.cloudflare_api import list_account_zones
from app.web.config_loader import load_encrypted_config, load_encrypted_config_with_cipher, config_file_path
@ -23,6 +24,20 @@ def _read_worker_template(filename):
email_bp = Blueprint('email', __name__, url_prefix='/email')
def _webmail_origin():
request_origin = request.headers.get('Origin', '')
if not request_origin:
return '*'
# Allow any origin that looks like our mail subdomains
if '.dockflare.app' in request_origin or 'localhost' in request_origin or '127.0.0.1' in request_origin:
return request_origin
# Fallback to the first domain or *
domains = config.EMAIL_CONFIG.get('domains', {})
first_domain = next(iter(domains), '')
return f"https://mail.{first_domain}" if first_domain else '*'
def save_email_config(email_config_data):
cfg, fernet = load_encrypted_config_with_cipher()
if not cfg or not fernet:
@ -330,35 +345,98 @@ def sso_callback():
return "No webmail domain configured.", 500
return redirect(f"https://{return_to}/auth/callback?token={token}")
def _generate_jwt(username):
def _generate_jwt(username, mailboxes=None, role='admin', expiry_seconds=None):
email_cfg = config.EMAIL_CONFIG
if not email_cfg or 'jwt_signing_key' not in email_cfg:
return None
private_key_pem = email_cfg['jwt_signing_key']
private_key = serialization.load_pem_private_key(
private_key_pem.encode('utf-8'),
email_cfg['jwt_signing_key'].encode('utf-8'),
password=None
)
mailboxes = []
for d, d_data in email_cfg.get('domains', {}).items():
for m in d_data.get('mailboxes', {}).keys():
mailboxes.append(m)
if mailboxes is None:
mailboxes = [
m for d in email_cfg.get('domains', {}).values()
for m in d.get('mailboxes', {}).keys()
]
if expiry_seconds is None:
expiry_seconds = config.EMAIL_JWT_EXPIRY_SECONDS
now = int(time.time())
payload = {
"sub": username,
"iss": config.EMAIL_JWT_ISSUER,
"aud": config.EMAIL_JWT_AUDIENCE,
"iat": now,
"exp": now + config.EMAIL_JWT_EXPIRY_SECONDS,
"exp": now + expiry_seconds,
"mailboxes": mailboxes,
"role": "admin"
"role": role,
}
token = jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
return token
return jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
@email_bp.route('/mailbox/set-password', methods=['POST'])
@login_required
def set_mailbox_password():
data = request.get_json(force=True, silent=True) or {}
address = data.get('address', '')
domain = data.get('domain', '')
password = data.get('password', '')
if len(password) < 8:
return jsonify({'success': False, 'error': 'Password must be at least 8 characters'}), 400
email_cfg = config.EMAIL_CONFIG
if domain not in email_cfg.get('domains', {}) or address not in email_cfg['domains'][domain].get('mailboxes', {}):
return jsonify({'success': False, 'error': 'Mailbox not found'}), 404
email_cfg['domains'][domain]['mailboxes'][address]['password_hash'] = generate_password_hash(password)
save_email_config(email_cfg)
return jsonify({'success': True})
@email_bp.route('/auth/login', methods=['POST', 'OPTIONS'])
@limiter.limit("5 per 5 minutes")
def mailbox_login():
origin = _webmail_origin()
if request.method == 'OPTIONS':
response = current_app.make_default_options_response()
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Allow-Methods'] = 'POST'
return response
data = request.get_json(force=True, silent=True) or {}
email = data.get('email', '').lower().strip()
password = data.get('password', '')
email_cfg = config.EMAIL_CONFIG
mailbox_data = None
for d in email_cfg.get('domains', {}).values():
if email in d.get('mailboxes', {}):
mailbox_data = d['mailboxes'][email]
break
_dummy = 'pbkdf2:sha256:600000$dummy$' + 'a' * 64
stored_hash = mailbox_data.get('password_hash', '') if mailbox_data else _dummy
if not stored_hash or not check_password_hash(stored_hash, password) or mailbox_data is None:
response = jsonify({'success': False, 'error': 'Invalid email or password'})
response.headers['Access-Control-Allow-Origin'] = origin
return response, 401
token = _generate_jwt(email, mailboxes=[email], role='user', expiry_seconds=28800)
if not token:
return jsonify({'success': False, 'error': 'Auth configuration error'}), 500
response = jsonify({'success': True, 'token': token})
response.headers['Access-Control-Allow-Origin'] = origin
return response
def _check_internal_request():
# Block any request that carries Cloudflare edge headers (all public internet

View file

@ -188,7 +188,7 @@ def gating_logic():
return
if not current_user.is_authenticated:
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config']
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config', 'email.mailbox_login']
oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login']
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints:
try:
@ -229,7 +229,7 @@ def add_security_headers_bp(response):
"style-src": ["'self'", "'unsafe-inline'", "https://rsms.me", "https://cdn.jsdelivr.net"],
"img-src": ["'self'", "data:", "https://img.shields.io"],
"font-src": ["'self'", "https://rsms.me"],
"connect-src": ["'self'", "https://cdn.jsdelivr.net"],
"connect-src": ["'self'", "https://cdn.jsdelivr.net", "https://mail.*"],
"frame-src": ["'none'"]
}
if is_https:
@ -455,7 +455,6 @@ def access_policies_page():
policy = access_groups[default_bypass_id]
cf_policy_id = policy.get("cf_policy_id")
# If no Cloudflare policy ID, create it now
if not cf_policy_id or cf_policy_id == default_bypass_id:
try:
cf_policy = reusable_policies.create_reusable_policy(

View file

@ -1,4 +1,45 @@
#!/bin/sh
MASTER_URL="${DOCKFLARE_MASTER_URL:-}"
INTERNAL_URL="${DOCKFLARE_INTERNAL_URL:-http://dockflare:5000}"
echo "{\"masterUrl\": \"${MASTER_URL}\"}" > /usr/share/nginx/html/config.json
cat > /etc/nginx/conf.d/default.conf << EOF
server {
listen 80;
server_name _;
client_max_body_size 25m;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
location /api/ {
proxy_pass http://dockflare-mail-manager:8025/api/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location = /email/auth/login {
proxy_pass ${INTERNAL_URL}/email/auth/login;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
location = /config.json {
root /usr/share/nginx/html;
add_header Cache-Control "no-store";
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files \$uri \$uri/ /index.html;
}
location ~* \\.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|mp4|webm)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
EOF
exec nginx -g "daemon off;"

View file

@ -2,7 +2,7 @@ server {
listen 80;
server_name _;
client_max_body_size 25m;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';";
add_header Content-Security-Policy "connect-src *; default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; font-src 'self' data: https://r2cdn.perplexity.ai https://rsms.me;" always;
location /api/ {
proxy_pass http://dockflare-mail-manager:8025/api/;

View file

@ -1,5 +1,14 @@
import apiClient from './client';
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
loginWithPassword: async (baseUrl, email, password) => {
const url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const response = await fetch(`${url}/email/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return response.json();
},
};
//# sourceMappingURL=auth.js.map

View file

@ -1 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;CAC3C,CAAA"}
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;IAE1C,iBAAiB,EAAE,KAAK,EAAE,OAAe,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC5E,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;QAClE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,GAAG,mBAAmB,EAAE;YACtD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;SAC1C,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;CACF,CAAA"}

View file

@ -2,4 +2,13 @@ import apiClient from './client'
export const authApi = {
checkAuth: () => apiClient.get('/auth/me'),
}
loginWithPassword: async (email: string, password: string) => {
const response = await fetch('/email/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
return response.json()
},
}

View file

@ -150,7 +150,7 @@ const performEmptyTrash = async () => {
<div class="flex flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<MessageListItem
v-for="msg in filteredMessages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:selected="store.currentMessage?.id === msg.id"
@ -158,7 +158,7 @@ const performEmptyTrash = async () => {
@click="selectMessage(msg)"
/>
</TransitionGroup>
<div v-if="filteredMessages.length === 0" class="p-8 text-center text-muted-foreground">
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
No messages found.
</div>
</div>
@ -175,7 +175,7 @@ const performEmptyTrash = async () => {
<div class="flex flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<MessageListItem
v-for="msg in unreadMessages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:selected="store.currentMessage?.id === msg.id"
@ -183,7 +183,7 @@ const performEmptyTrash = async () => {
@click="selectMessage(msg)"
/>
</TransitionGroup>
<div v-if="unreadMessages.length === 0" class="p-8 text-center text-muted-foreground">
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
No unread messages.
</div>
</div>
@ -200,7 +200,7 @@ const performEmptyTrash = async () => {
<div class="flex flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<MessageListItem
v-for="msg in starredMessages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:selected="store.currentMessage?.id === msg.id"
@ -208,7 +208,7 @@ const performEmptyTrash = async () => {
@click="selectMessage(msg)"
/>
</TransitionGroup>
<div v-if="starredMessages.length === 0" class="p-8 text-center text-muted-foreground">
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
No starred messages.
</div>
</div>

View file

@ -266,7 +266,7 @@ const __VLS_49 = __VLS_48({
appear: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_48));
__VLS_50.slots.default;
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.filteredMessages))) {
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
/** @type {[typeof MessageListItem, ]} */ ;
// @ts-ignore
const __VLS_51 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
@ -294,7 +294,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.filteredMessages))) {
var __VLS_53;
}
var __VLS_50;
if (__VLS_ctx.filteredMessages.length === 0) {
if (__VLS_ctx.displayMessages.length === 0) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "p-8 text-center text-muted-foreground" },
});
@ -371,7 +371,7 @@ const __VLS_80 = __VLS_79({
appear: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_79));
__VLS_81.slots.default;
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.unreadMessages))) {
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
/** @type {[typeof MessageListItem, ]} */ ;
// @ts-ignore
const __VLS_82 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
@ -399,7 +399,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.unreadMessages))) {
var __VLS_84;
}
var __VLS_81;
if (__VLS_ctx.unreadMessages.length === 0) {
if (__VLS_ctx.displayMessages.length === 0) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "p-8 text-center text-muted-foreground" },
});
@ -476,7 +476,7 @@ const __VLS_111 = __VLS_110({
appear: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_110));
__VLS_112.slots.default;
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.starredMessages))) {
for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.displayMessages))) {
/** @type {[typeof MessageListItem, ]} */ ;
// @ts-ignore
const __VLS_113 = __VLS_asFunctionalComponent(MessageListItem, new MessageListItem({
@ -504,7 +504,7 @@ for (const [msg] of __VLS_getVForSourceType((__VLS_ctx.starredMessages))) {
var __VLS_115;
}
var __VLS_112;
if (__VLS_ctx.starredMessages.length === 0) {
if (__VLS_ctx.displayMessages.length === 0) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "p-8 text-center text-muted-foreground" },
});
@ -810,9 +810,7 @@ const __VLS_self = (await import('vue')).defineComponent({
searchValue: searchValue,
showTrashConfirm: showTrashConfirm,
folderColor: folderColor,
filteredMessages: filteredMessages,
unreadMessages: unreadMessages,
starredMessages: starredMessages,
displayMessages: displayMessages,
toggleSort: toggleSort,
selectMessage: selectMessage,
emptyTrash: emptyTrash,

File diff suppressed because one or more lines are too long

View file

@ -1,19 +1,27 @@
import { ref } from 'vue'
import { mailApi } from '../api/mail'
import { useMailStore } from '../stores/mail'
import { useAuthStore } from '../stores/auth'
export function useMail() {
const store = useMailStore()
const authStore = useAuthStore()
const loading = ref(false)
const error = ref('')
const loadMailboxes = async () => {
loading.value = true
try {
const res = await mailApi.getMailboxes()
store.mailboxes = res.data
if (res.data.length > 0 && !store.currentMailbox) {
store.currentMailbox = res.data[0].address
const decoded = authStore.decodeToken()
if (decoded?.role === 'user') {
const addresses: string[] = decoded.mailboxes || []
store.mailboxes = addresses.map((addr: string) => ({ address: addr, display_name: addr }))
} else {
const res = await mailApi.getMailboxes()
store.mailboxes = res.data
}
if (store.mailboxes.length > 0 && !store.currentMailbox) {
store.currentMailbox = store.mailboxes[0].address
}
} catch (e: any) {
error.value = e.message

View file

@ -1,12 +1,18 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '../composables/useAuth'
import { authApi } from '../api/auth'
import Button from '../components/ui/Button.vue'
const route = useRoute()
const { login } = useAuth()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
onMounted(() => {
const token = route.query.token as string
if (token) {
@ -14,15 +20,36 @@ onMounted(() => {
}
})
const redirectToMaster = async () => {
let masterUrl = import.meta.env.VITE_MASTER_URL
if (!masterUrl) {
const getMasterUrl = async (): Promise<string> => {
let url = import.meta.env.VITE_MASTER_URL as string
if (!url) {
try {
const cfg = await fetch('/config.json').then(r => r.json())
masterUrl = cfg.masterUrl
url = cfg.masterUrl
} catch {}
}
if (!masterUrl) masterUrl = window.location.origin.replace('mail.', '')
return url || window.location.origin.replace('mail.', '')
}
const handleLogin = async () => {
error.value = ''
loading.value = true
try {
const data = await authApi.loginWithPassword(email.value, password.value)
if (data.success && data.token) {
login(data.token)
} else {
error.value = data.error || 'Invalid email or password'
}
} catch {
error.value = 'Connection error. Please try again.'
} finally {
loading.value = false
}
}
const redirectToMaster = async () => {
const masterUrl = await getMasterUrl()
window.location.href = `${masterUrl}/email/sso/callback?return_to=${window.location.hostname}`
}
</script>
@ -32,9 +59,39 @@ const redirectToMaster = async () => {
<div class="w-full max-w-sm space-y-6 rounded-lg border p-8 shadow-sm">
<div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Login to Webmail</h1>
<p class="text-sm text-muted-foreground">Sign in via DockFlare Master</p>
<p class="text-sm text-muted-foreground">Sign in with your email and password</p>
</div>
<Button class="w-full" @click="redirectToMaster">Login with SSO</Button>
<form @submit.prevent="handleLogin" class="space-y-3">
<input
v-model="email"
type="email"
placeholder="you@example.com"
required
class="input input-bordered w-full"
/>
<input
v-model="password"
type="password"
placeholder="Password"
required
class="input input-bordered w-full"
/>
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<Button type="submit" class="w-full" :disabled="loading">
{{ loading ? 'Signing in…' : 'Sign in' }}
</Button>
</form>
<div class="flex items-center gap-2">
<div class="flex-1 border-t" />
<span class="text-xs text-muted-foreground">or</span>
<div class="flex-1 border-t" />
</div>
<Button variant="outline" class="w-full" @click="redirectToMaster">
Admin SSO
</Button>
</div>
</div>
</template>
</template>

View file

@ -1,27 +1,54 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
import { onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAuth } from '../composables/useAuth';
import { authApi } from '../api/auth';
import Button from '../components/ui/Button.vue';
const route = useRoute();
const { login } = useAuth();
const email = ref('');
const password = ref('');
const error = ref('');
const loading = ref(false);
onMounted(() => {
const token = route.query.token;
if (token) {
login(token);
}
});
const redirectToMaster = async () => {
let masterUrl = import.meta.env.VITE_MASTER_URL;
if (!masterUrl) {
const getMasterUrl = async () => {
let url = import.meta.env.VITE_MASTER_URL;
if (!url) {
try {
const cfg = await fetch('/config.json').then(r => r.json());
masterUrl = cfg.masterUrl;
url = cfg.masterUrl;
}
catch { }
}
if (!masterUrl)
masterUrl = window.location.origin.replace('mail.', '');
return url || window.location.origin.replace('mail.', '');
};
const handleLogin = async () => {
error.value = '';
loading.value = true;
try {
const masterUrl = await getMasterUrl();
const data = await authApi.loginWithPassword(masterUrl, email.value, password.value);
if (data.success && data.token) {
login(data.token);
}
else {
error.value = data.error || 'Invalid email or password';
}
}
catch {
error.value = 'Connection error. Please try again.';
}
finally {
loading.value = false;
}
};
const redirectToMaster = async () => {
const masterUrl = await getMasterUrl();
window.location.href = `${masterUrl}/email/sso/callback?return_to=${window.location.hostname}`;
};
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
@ -43,24 +70,77 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-muted-foreground" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.form, __VLS_intrinsicElements.form)({
...{ onSubmit: (__VLS_ctx.handleLogin) },
...{ class: "space-y-3" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "email",
placeholder: "you@example.com",
required: true,
...{ class: "input input-bordered w-full" },
});
(__VLS_ctx.email);
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
type: "password",
placeholder: "Password",
required: true,
...{ class: "input input-bordered w-full" },
});
(__VLS_ctx.password);
if (__VLS_ctx.error) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-sm text-destructive" },
});
(__VLS_ctx.error);
}
/** @type {[typeof Button, typeof Button, ]} */ ;
// @ts-ignore
const __VLS_0 = __VLS_asFunctionalComponent(Button, new Button({
...{ 'onClick': {} },
type: "submit",
...{ class: "w-full" },
disabled: (__VLS_ctx.loading),
}));
const __VLS_1 = __VLS_0({
...{ 'onClick': {} },
type: "submit",
...{ class: "w-full" },
disabled: (__VLS_ctx.loading),
}, ...__VLS_functionalComponentArgsRest(__VLS_0));
let __VLS_3;
let __VLS_4;
let __VLS_5;
const __VLS_6 = {
__VLS_2.slots.default;
(__VLS_ctx.loading ? 'Signing in…' : 'Sign in');
var __VLS_2;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div)({
...{ class: "flex-1 border-t" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-xs text-muted-foreground" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div)({
...{ class: "flex-1 border-t" },
});
/** @type {[typeof Button, typeof Button, ]} */ ;
// @ts-ignore
const __VLS_3 = __VLS_asFunctionalComponent(Button, new Button({
...{ 'onClick': {} },
variant: "outline",
...{ class: "w-full" },
}));
const __VLS_4 = __VLS_3({
...{ 'onClick': {} },
variant: "outline",
...{ class: "w-full" },
}, ...__VLS_functionalComponentArgsRest(__VLS_3));
let __VLS_6;
let __VLS_7;
let __VLS_8;
const __VLS_9 = {
onClick: (__VLS_ctx.redirectToMaster)
};
__VLS_2.slots.default;
var __VLS_2;
__VLS_5.slots.default;
var __VLS_5;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['h-screen']} */ ;
/** @type {__VLS_StyleScopedClasses['w-screen']} */ ;
@ -83,12 +163,36 @@ var __VLS_2;
/** @type {__VLS_StyleScopedClasses['tracking-tight']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-3']} */ ;
/** @type {__VLS_StyleScopedClasses['input']} */ ;
/** @type {__VLS_StyleScopedClasses['input-bordered']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['input']} */ ;
/** @type {__VLS_StyleScopedClasses['input-bordered']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['text-destructive']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['text-muted-foreground']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
return {
Button: Button,
email: email,
password: password,
error: error,
loading: loading,
handleLogin: handleLogin,
redirectToMaster: redirectToMaster,
};
},

File diff suppressed because one or more lines are too long