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.
OneAuth/ui/page/user/sessions.html

327 lines
8.8 KiB
HTML

<!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>