feat: limit concurrent sessions per user with active session management (CW-7169)
This commit is contained in:
committed by
Vishnu Narayanan
parent
3eed8905cc
commit
13ad505bbb
@@ -106,4 +106,10 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const revokingId = ref(null);
|
||||
const revokingAll = ref(false);
|
||||
|
||||
const sortedSessions = computed(() =>
|
||||
[...props.sessions].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'hh:mma');
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
const handleRevoke = session => {
|
||||
revokingId.value = session.id;
|
||||
emit('revoke', session.id);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
revokingAll.value = true;
|
||||
emit('revokeAll');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('SESSION_LIMIT.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
faded
|
||||
sm
|
||||
class="flex-shrink-0 whitespace-nowrap"
|
||||
:label="$t('SESSION_LIMIT.END_ALL')"
|
||||
:is-loading="revokingAll"
|
||||
:disabled="revokingId !== null"
|
||||
@click="handleRevokeAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="session in sortedSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-monitor"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
|
||||
:disabled="revokingId !== null || revokingAll"
|
||||
@click="handleRevoke(session)"
|
||||
>
|
||||
{{ $t('SESSION_LIMIT.END') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel -->
|
||||
<div class="text-center pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:label="$t('SESSION_LIMIT.CANCEL')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import sessionLimit from './sessionLimit.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -85,5 +86,6 @@ export default {
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...sessionLimit,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"SESSION_LIMIT": {
|
||||
"TITLE": "Active session limit reached",
|
||||
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
|
||||
"END": "End",
|
||||
"END_ALL": "End all sessions",
|
||||
"LOG_IN": "Log in",
|
||||
"CANCEL": "Back to login",
|
||||
"UNKNOWN_DEVICE": "Unknown device",
|
||||
"STARTED": "Started"
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color-scheme="alert"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import ActiveSessions from './ActiveSessions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
ActiveSessions,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -307,6 +309,13 @@ export default {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -43,6 +43,15 @@ export const login = async ({
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
if (
|
||||
error.response?.status === 409 &&
|
||||
error.response?.data?.sessions_limit_reached
|
||||
) {
|
||||
return {
|
||||
sessionsLimitReached: true,
|
||||
sessions: error.response.data.sessions,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
|
||||
@@ -17,6 +17,7 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
@@ -36,6 +37,7 @@ export default {
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
SessionLimitOverlay,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -68,6 +70,8 @@ export default {
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
sessionsLimitReached: false,
|
||||
limitedSessions: [],
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -182,6 +186,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if sessions limit reached
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -224,6 +236,50 @@ export default {
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
retryLoginWithParams(extraParams) {
|
||||
const credentials = {
|
||||
email: this.email
|
||||
? decodeURIComponent(this.email)
|
||||
: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
ssoConversationId: this.ssoConversationId,
|
||||
...extraParams,
|
||||
};
|
||||
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.loginApi.showLoading = true;
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
return;
|
||||
}
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
this.loginApi.hasErrored = true;
|
||||
this.showAlertMessage(
|
||||
response?.message || this.$t('LOGIN.API.UNAUTH')
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSessionRevoke(sessionId) {
|
||||
this.retryLoginWithParams({ revoke_session_id: sessionId });
|
||||
},
|
||||
handleSessionRevokeAll() {
|
||||
this.retryLoginWithParams({ revoke_all_sessions: true });
|
||||
},
|
||||
handleSessionLimitCancel() {
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -255,8 +311,18 @@ export default {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Session Limit Section -->
|
||||
<section v-if="sessionsLimitReached" class="mt-11">
|
||||
<SessionLimitOverlay
|
||||
:sessions="limitedSessions"
|
||||
@revoke="handleSessionRevoke"
|
||||
@revoke-all="handleSessionRevokeAll"
|
||||
@cancel="handleSessionLimitCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<section v-else-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
|
||||
Reference in New Issue
Block a user