mirror of https://github.com/veypi/OneAuth.git
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
327 lines
8.8 KiB
HTML
327 lines
8.8 KiB
HTML
|
4 days ago
|
<!DOCTYPE html>
|
||
|
|
<html>
|
||
|
|
|
||
|
|
<head>
|
||
|
|
<meta name="description" content="Session Management">
|
||
|
|
<title>{{ $t('session.title') }}</title>
|
||
|
|
<style>
|
||
|
|
body {
|
||
|
|
background-color: var(--bg-color);
|
||
|
|
display: flex;
|
||
|
|
justify-content: center;
|
||
|
|
padding-top: var(--spacing-xl);
|
||
|
|
padding-bottom: var(--spacing-xl);
|
||
|
|
box-sizing: border-box;
|
||
|
|
min-height: 100vh;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sessions-container {
|
||
|
|
width: 100%;
|
||
|
|
max-width: 800px;
|
||
|
|
padding: var(--spacing-xl);
|
||
|
|
background: var(--bg-color-secondary);
|
||
|
|
border-radius: var(--radius-lg);
|
||
|
|
box-shadow: var(--shadow-sm);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: var(--spacing-lg);
|
||
|
|
height: fit-content;
|
||
|
|
}
|
||
|
|
|
||
|
|
.section-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.section-title {
|
||
|
|
font-size: 1.25rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--color-text);
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-list {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: var(--spacing-md);
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
padding: var(--spacing-md) var(--spacing-lg);
|
||
|
|
background: var(--bg-color);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: var(--radius-md);
|
||
|
|
transition: border-color 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-item:hover {
|
||
|
|
border-color: var(--color-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-item.current {
|
||
|
|
border-color: var(--color-primary);
|
||
|
|
background: var(--color-primary-light, #f0f7ff);
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-left {
|
||
|
|
display: flex;
|
||
|
|
align-items: flex-start;
|
||
|
|
gap: var(--spacing-md);
|
||
|
|
flex: 1;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-icon {
|
||
|
|
width: 40px;
|
||
|
|
height: 40px;
|
||
|
|
border-radius: 50%;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
font-size: 18px;
|
||
|
|
flex-shrink: 0;
|
||
|
|
background: var(--bg-color-secondary);
|
||
|
|
color: var(--color-text-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-item.current .session-icon {
|
||
|
|
background: var(--color-primary);
|
||
|
|
color: var(--color-primary-text);
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-info {
|
||
|
|
flex: 1;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-device {
|
||
|
|
font-weight: 500;
|
||
|
|
color: var(--color-text);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: var(--spacing-xs);
|
||
|
|
white-space: nowrap;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-meta {
|
||
|
|
display: flex;
|
||
|
|
gap: var(--spacing-md);
|
||
|
|
margin-top: 4px;
|
||
|
|
font-size: 0.85rem;
|
||
|
|
color: var(--color-text-light);
|
||
|
|
flex-wrap: wrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-meta span {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.current-badge {
|
||
|
|
font-size: 0.75rem;
|
||
|
|
background: var(--color-primary);
|
||
|
|
color: var(--color-primary-text);
|
||
|
|
padding: 2px 8px;
|
||
|
|
border-radius: 10px;
|
||
|
|
margin-left: var(--spacing-xs);
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.session-actions {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: var(--spacing-sm);
|
||
|
|
flex-shrink: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state {
|
||
|
|
text-align: center;
|
||
|
|
padding: var(--spacing-xl);
|
||
|
|
color: var(--color-text-light);
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state i {
|
||
|
|
font-size: 48px;
|
||
|
|
margin-bottom: var(--spacing-md);
|
||
|
|
display: block;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
|
||
|
|
<body>
|
||
|
|
<div class="sessions-container">
|
||
|
|
<div class="section-header">
|
||
|
|
<h2 class="section-title">{{ $t('session.title') }}</h2>
|
||
|
|
<v-btn v-if="otherSessions.length > 0" size="sm" variant="outline" color="danger" :click="revokeAllOthers">
|
||
|
|
{{ $t('session.revoke_others') }}
|
||
|
|
</v-btn>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="loading" style="text-align: center; padding: var(--spacing-xl);">
|
||
|
|
{{ $t('common.processing') }}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="session-list">
|
||
|
|
<div v-if="sessions.length === 0" class="empty-state">
|
||
|
|
<i class="fas fa-shield-alt"></i>
|
||
|
|
<p>{{ $t('session.empty') }}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-for="s in sessions"
|
||
|
|
class="session-item"
|
||
|
|
:class="{ current: s.is_current }">
|
||
|
|
<div class="session-left">
|
||
|
|
<div class="session-icon">
|
||
|
|
<i class="fas" :class="getDeviceIcon(s.device_info)"></i>
|
||
|
|
</div>
|
||
|
|
<div class="session-info">
|
||
|
|
<div class="session-device">
|
||
|
|
{{ formatDevice(s.device_info) }}
|
||
|
|
<span v-if="s.is_current" class="current-badge">{{ $t('session.current') }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="session-meta">
|
||
|
|
<span><i class="fas fa-map-marker-alt"></i> {{ s.ip || '-' }}</span>
|
||
|
|
<span><i class="fas fa-clock"></i> {{ formatLoginTime(s.created_at) }}</span>
|
||
|
|
<span v-if="s.expires_at"><i class="fas fa-hourglass-half"></i> {{ $t('session.expires') }}: {{ formatExpiry(s.expires_at) }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="session-actions">
|
||
|
|
<v-btn v-if="!s.is_current" size="sm" variant="outline" color="danger" :click="() => revokeSession(s.id)">
|
||
|
|
{{ $t('session.revoke') }}
|
||
|
|
</v-btn>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
<script setup>
|
||
|
|
sessions = [];
|
||
|
|
loading = true;
|
||
|
|
|
||
|
|
loadSessions = async () => {
|
||
|
|
loading = true;
|
||
|
|
try {
|
||
|
|
sessions = await $fetch('/api/auth/sessions') || [];
|
||
|
|
} catch (e) {
|
||
|
|
$message.error(e.message);
|
||
|
|
} finally {
|
||
|
|
loading = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
revokeSession = async (id) => {
|
||
|
|
try {
|
||
|
|
await $message.confirm($t('session.revoke_confirm'));
|
||
|
|
await $fetch(`/api/auth/sessions/${id}`, { method: 'DELETE' });
|
||
|
|
$message.success($t('session.revoke_success'));
|
||
|
|
loadSessions();
|
||
|
|
} catch (e) {
|
||
|
|
if (e !== 'cancel') $message.error(e.message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
revokeAllOthers = async () => {
|
||
|
|
try {
|
||
|
|
await $message.confirm($t('session.revoke_others_confirm'));
|
||
|
|
await $fetch('/api/auth/sessions', { method: 'DELETE' });
|
||
|
|
$message.success($t('session.revoke_success'));
|
||
|
|
loadSessions();
|
||
|
|
} catch (e) {
|
||
|
|
if (e !== 'cancel') $message.error(e.message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
otherSessions = () => sessions.filter(s => !s.is_current);
|
||
|
|
|
||
|
|
getDeviceIcon = (ua) => {
|
||
|
|
if (!ua) return 'fa-desktop';
|
||
|
|
const lower = ua.toLowerCase();
|
||
|
|
if (lower.indexOf('iphone') > -1 || lower.indexOf('ipad') > -1) return 'fa-mobile-alt';
|
||
|
|
if (lower.indexOf('android') > -1) return 'fa-android';
|
||
|
|
if (lower.indexOf('macintosh') > -1 || lower.indexOf('mac os') > -1) return 'fa-apple';
|
||
|
|
if (lower.indexOf('windows') > -1) return 'fa-windows';
|
||
|
|
if (lower.indexOf('linux') > -1) return 'fa-linux';
|
||
|
|
if (lower.indexOf('chrome') > -1 || lower.indexOf('firefox') > -1 || lower.indexOf('safari') > -1) return 'fa-globe';
|
||
|
|
return 'fa-desktop';
|
||
|
|
};
|
||
|
|
|
||
|
|
formatDevice = (ua) => {
|
||
|
|
if (!ua) return '-';
|
||
|
|
// Extract browser + OS name from UA string
|
||
|
|
let name = '';
|
||
|
|
if (ua.indexOf('iPhone') > -1) {
|
||
|
|
name = 'iPhone';
|
||
|
|
} else if (ua.indexOf('iPad') > -1) {
|
||
|
|
name = 'iPad';
|
||
|
|
} else if (ua.indexOf('Android') > -1) {
|
||
|
|
name = 'Android';
|
||
|
|
} else if (ua.indexOf('Macintosh') > -1 || ua.indexOf('Mac OS') > -1) {
|
||
|
|
name = 'Mac';
|
||
|
|
} else if (ua.indexOf('Windows') > -1) {
|
||
|
|
name = 'Windows';
|
||
|
|
} else if (ua.indexOf('Linux') > -1) {
|
||
|
|
name = 'Linux';
|
||
|
|
} else {
|
||
|
|
name = 'Unknown';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Browser
|
||
|
|
if (ua.indexOf('Edg') > -1) name += ' · Edge';
|
||
|
|
else if (ua.indexOf('Chrome') > -1) name += ' · Chrome';
|
||
|
|
else if (ua.indexOf('Firefox') > -1) name += ' · Firefox';
|
||
|
|
else if (ua.indexOf('Safari') > -1) name += ' · Safari';
|
||
|
|
|
||
|
|
return name;
|
||
|
|
};
|
||
|
|
|
||
|
|
parseISO = (t) => {
|
||
|
|
if (!t) return null;
|
||
|
|
// 兼容 Safari 不支持 +08:00 冒号格式
|
||
|
|
const s = t.replace(/([+-]\d{2}):(\d{2})$/, '$1$2');
|
||
|
|
const d = new Date(s);
|
||
|
|
return isNaN(d.getTime()) ? null : d;
|
||
|
|
};
|
||
|
|
|
||
|
|
formatLoginTime = (t) => {
|
||
|
|
const d = parseISO(t);
|
||
|
|
if (!d) return '-';
|
||
|
|
const now = new Date();
|
||
|
|
const diff = now - d;
|
||
|
|
if (diff < 0) return formatDate(d);
|
||
|
|
if (diff < 60000) return $t('session.just_now');
|
||
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + ' ' + $t('session.minutes_ago');
|
||
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + ' ' + $t('session.hours_ago');
|
||
|
|
if (diff < 604800000) return Math.floor(diff / 86400000) + ' ' + $t('session.days_ago');
|
||
|
|
return formatDate(d);
|
||
|
|
};
|
||
|
|
|
||
|
|
formatExpiry = (t) => {
|
||
|
|
const d = parseISO(t);
|
||
|
|
if (!d) return '-';
|
||
|
|
return formatDate(d);
|
||
|
|
};
|
||
|
|
|
||
|
|
formatDate = (d) => {
|
||
|
|
const y = d.getFullYear();
|
||
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
|
|
const day = String(d.getDate()).padStart(2, '0');
|
||
|
|
const h = String(d.getHours()).padStart(2, '0');
|
||
|
|
const min = String(d.getMinutes()).padStart(2, '0');
|
||
|
|
return y + '-' + m + '-' + day + ' ' + h + ':' + min;
|
||
|
|
};
|
||
|
|
</script>
|
||
|
|
<script>
|
||
|
|
$data.loadSessions();
|
||
|
|
</script>
|
||
|
|
|
||
|
|
</html>
|