Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-2136-moin-schule-logout-in-svs #3444

Merged
merged 17 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/modules/data/system/SystemApi.composable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const useSystemApi = () => {

const system: System = {
id: response.data.id,
alias: response.data.alias,
displayName: response.data.displayName ?? "",
};

Expand Down
2 changes: 2 additions & 0 deletions src/modules/data/system/type/System.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export interface System {
id: string;

alias?: string | null;
GordonNicholasCap marked this conversation as resolved.
Show resolved Hide resolved

displayName: string;
}
5 changes: 5 additions & 0 deletions src/modules/ui/layout/topbar/Topbar.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createModuleMocks } from "@@/tests/test-utils/mock-store-module";
import {
AUTH_MODULE_KEY,
ENV_CONFIG_MODULE_KEY,
NOTIFIER_MODULE_KEY,
STATUS_ALERTS_MODULE_KEY,
} from "@/utils/inject";
import AuthModule from "@/store/auth";
Expand All @@ -18,6 +19,7 @@ import { VApp } from "vuetify/lib/components/index.mjs";
import { envsFactory } from "@@/tests/test-utils";
import EnvConfigModule from "@/store/env-config";
import { SchulcloudTheme } from "@/serverApi/v3";
import NotifierModule from "@/store/notifier";

describe("@ui-layout/Topbar", () => {
const setup = async (windowWidth = 1300, isSidebarExpanded?: boolean) => {
Expand Down Expand Up @@ -47,6 +49,8 @@ describe("@ui-layout/Topbar", () => {
getStatusAlerts: mockStatusAlerts,
});

const notifierModule = createModuleMocks(NotifierModule);
GordonNicholasCap marked this conversation as resolved.
Show resolved Hide resolved

Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
Expand All @@ -60,6 +64,7 @@ describe("@ui-layout/Topbar", () => {
[AUTH_MODULE_KEY.valueOf()]: authModule,
[ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModule,
[STATUS_ALERTS_MODULE_KEY.valueOf()]: statusAlertsModule,
[NOTIFIER_MODULE_KEY.valueOf()]: notifierModule,
},
},
slots: {
Expand Down
183 changes: 178 additions & 5 deletions src/modules/ui/layout/topbar/UserMenu.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,58 @@ import {
createTestingVuetify,
} from "@@/tests/test-utils/setup";
import { createModuleMocks } from "@@/tests/test-utils/mock-store-module";
import { AUTH_MODULE_KEY, ENV_CONFIG_MODULE_KEY } from "@/utils/inject";
import { envsFactory } from "@@/tests/test-utils";
import {
AUTH_MODULE_KEY,
ENV_CONFIG_MODULE_KEY,
NOTIFIER_MODULE_KEY,
} from "@/utils/inject";
import AuthModule from "@/store/auth";
import EnvConfigModule from "@/store/env-config";
import { LanguageType } from "@/serverApi/v3";
import { System, useSystemApi } from "@data-system";
import { createMock, DeepMocked } from "@golevelup/ts-jest";
import NotifierModule from "@/store/notifier";
import { VBtn } from "vuetify/lib/components/index.mjs";

jest.mock("@data-system");

describe("@ui-layout/UserMenu", () => {
const setup = () => {
let useSystemApiMock: DeepMocked<ReturnType<typeof useSystemApi>>;

const setupWrapper = (
isExternalFeatureEnabled = false,
mockedSystem?: System
) => {
const authModule = createModuleMocks(AuthModule, {
getLocale: "de",
logout: jest.fn(),
externalLogout: jest.fn(),
getAccessToken:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic3lzdGVtSWQiOiJhYmMxMjMiLCJpYX" +
"QiOjE1MTYyMzkwMjJ9.Zrpvm5T50Y_s-pd5OoqdxKRBaf3rJGMAviUz7Be84zA",
});

const envConfigModule = createModuleMocks(EnvConfigModule, {
getAvailableLanguages: [LanguageType.De, LanguageType.En],
getEnv: envsFactory.build({
FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED: isExternalFeatureEnabled,
}),
});

const notifierModule = createModuleMocks(NotifierModule);

useSystemApiMock = createMock<ReturnType<typeof useSystemApi>>();
jest.mocked(useSystemApi).mockReturnValue(useSystemApiMock);
useSystemApiMock.getSystem.mockResolvedValue(mockedSystem);

const wrapper = mount(UserMenu, {
global: {
plugins: [createTestingVuetify(), createTestingI18n()],
provide: {
[AUTH_MODULE_KEY.valueOf()]: authModule,
[ENV_CONFIG_MODULE_KEY.valueOf()]: envConfigModule,
[NOTIFIER_MODULE_KEY.valueOf()]: notifierModule,
},
},
props: {
Expand All @@ -41,15 +72,19 @@ describe("@ui-layout/UserMenu", () => {
return { wrapper, authModule };
};

afterEach(() => {
jest.clearAllMocks();
});

it("should render with correct user initials", async () => {
const { wrapper } = setup();
const { wrapper } = setupWrapper();

const initials = wrapper.findComponent("[data-testid=user-menu-btn]");
expect(initials.text()).toMatch("AD");
});

it("should render correct active user name with role", async () => {
const { wrapper } = setup();
const { wrapper } = setupWrapper();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");
Expand All @@ -61,7 +96,7 @@ describe("@ui-layout/UserMenu", () => {
});

it("should trigger logout function on logout item click", async () => {
const { wrapper, authModule } = setup();
const { wrapper, authModule } = setupWrapper();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");
Expand All @@ -72,4 +107,142 @@ describe("@ui-layout/UserMenu", () => {

expect(authModule.logout).toHaveBeenCalled();
});

describe("external logout", () => {
describe("when feature flag is enabled and the user is logged in from moin.schule", () => {
const setup = () => {
const mockedSystem: System = {
id: "testId",
displayName: "moin.schule",
alias: "SANIS",
};

const { wrapper, authModule } = setupWrapper(true, mockedSystem);

return { wrapper, authModule, mockedSystem };
};

it("should show the external logout button", async () => {
const { wrapper, mockedSystem } = setup();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");

const externalLogoutBtn = wrapper.findComponent(
"[data-testid=external-logout]"
);

expect(externalLogoutBtn.exists()).toBe(true);
expect(externalLogoutBtn.text()).toEqual(
`common.labels.logout Bildungscloud & ${mockedSystem.displayName}`
);
});

it("should trigger external logout function on logout item click", async () => {
const { wrapper, authModule } = setup();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");

const externalLogoutBtn = wrapper.findComponent(
"[data-testid=external-logout]"
);

expect(externalLogoutBtn.exists()).toBe(true);
await externalLogoutBtn.trigger("click");

expect(authModule.externalLogout).toHaveBeenCalled();
});

it("should show the correct text for the logout button", async () => {
const { wrapper } = setup();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");

const logoutBtn = wrapper.findComponent("[data-testid=logout]");

expect(logoutBtn.exists()).toBe(true);
expect(logoutBtn.text()).toEqual(`common.labels.logout Bildungscloud`);
});
});

describe("when feature flag is disabled", () => {
const setup = () => {
const mockedSystem: System = {
id: "testId",
displayName: "moin.schule",
alias: "SANIS",
};

const { wrapper } = setupWrapper(false, mockedSystem);

return { wrapper };
};

it("should not show the external logout button", async () => {
const { wrapper } = setup();

const menuBtn = wrapper.findComponent(VBtn);
await menuBtn.trigger("click");

const externalLogoutBtn = wrapper.findComponent(
"[data-testid=external-logout]"
);

expect(externalLogoutBtn.exists()).toBe(false);
});

it("should show the correct text for the logout button", async () => {
const { wrapper } = setup();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");

const logoutBtn = wrapper.findComponent("[data-testid=logout]");

expect(logoutBtn.exists()).toBe(true);
expect(logoutBtn.text()).toEqual("common.labels.logout");
});
});

describe("when user is not logged in from moin.schule", () => {
const setup = () => {
const mockedSystem: System = {
id: "testId",
displayName: "mock",
alias: "mock-system",
};

const { wrapper } = setupWrapper(true, mockedSystem);

return { wrapper };
};

it("should not show the external logout button", async () => {
const { wrapper } = setup();

const menuBtn = wrapper.findComponent(VBtn);
await menuBtn.trigger("click");

const externalLogoutBtn = wrapper.findComponent(
"[data-testid=external-logout]"
);

expect(externalLogoutBtn.exists()).toBe(false);
});

it("should show the correct text for the logout button", async () => {
const { wrapper } = setup();

const menuBtn = wrapper.findComponent({ name: "VBtn" });
await menuBtn.trigger("click");

const logoutBtn = wrapper.findComponent("[data-testid=logout]");

expect(logoutBtn.exists()).toBe(true);
expect(logoutBtn.text()).toEqual("common.labels.logout");
});
});
});
});
66 changes: 62 additions & 4 deletions src/modules/ui/layout/topbar/UserMenu.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<VMenu width="215">
<VMenu :width="isExternalLogoutAllowed ? 'auto' : '215'">
<template v-slot:activator="{ props }">
<VBtn
v-bind="props"
Expand All @@ -21,19 +21,33 @@
<VListItem href="/account" data-testid="account-link">
{{ $t("global.topbar.settings") }}
</VListItem>
<VListItem
v-if="isExternalLogoutAllowed"
data-testid="external-logout"
@click="externalLogout"
>
{{ $t("common.labels.logout")
}}{{ isExternalLogoutAllowed ? ` Bildungscloud & ${systemName}` : "" }}
</VListItem>
<VListItem data-testid="logout" @click="logout">
{{ $t("common.labels.logout") }}
{{ $t("common.labels.logout")
}}{{ isExternalLogoutAllowed ? " Bildungscloud" : "" }}
</VListItem>
</VList>
</VMenu>
</template>

<script setup lang="ts">
import { computed, PropType, toRef } from "vue";
import { computed, onMounted, PropType, Ref, ref, toRef } from "vue";
import { useI18n } from "vue-i18n";
import LanguageMenu from "./LanguageMenu.vue";
import { MeUserResponse } from "@/serverApi/v3";
import { injectStrict, AUTH_MODULE_KEY } from "@/utils/inject";
import {
injectStrict,
AUTH_MODULE_KEY,
ENV_CONFIG_MODULE_KEY,
} from "@/utils/inject";
import { System, useSystemApi } from "@data-system";

const props = defineProps({
user: {
Expand All @@ -48,6 +62,9 @@ const props = defineProps({

const { t } = useI18n();
const authModule = injectStrict(AUTH_MODULE_KEY);
const envConfigModule = injectStrict(ENV_CONFIG_MODULE_KEY);

const system = useSystemApi();

const userRole = computed(() => {
return t(`common.roleName.${toRef(props.roleNames).value[0]}`).toString();
Expand All @@ -57,9 +74,50 @@ const initials = computed(() => {
return props.user.firstName.slice(0, 1) + props.user.lastName.slice(0, 1);
});

const isExternalLogoutAllowed: Ref<boolean> = ref(false);

const systemName: Ref<string> = ref("");

const logout = () => {
authModule.logout();
};

const externalLogout = () => {
authModule.externalLogout();
};

const setSystemValuesFromJwt = async (jwt: string): Promise<void> => {
const jwtPayload = JSON.parse(atob(jwt.split(".")[1]));
if (!("systemId" in jwtPayload) && !jwtPayload.systemId) {
GordonNicholasCap marked this conversation as resolved.
Show resolved Hide resolved
return;
}

const fetchedSystem: System | undefined = await system.getSystem(
jwtPayload.systemId
);

if (!fetchedSystem || !fetchedSystem.alias) {
return;
}

isExternalLogoutAllowed.value =
fetchedSystem.alias === "SANIS" &&
envConfigModule.getEnv.FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED;
systemName.value = fetchedSystem.displayName;
};

onMounted(async () => {
const jwt = authModule.getAccessToken;
if (jwt) {
try {
await setSystemValuesFromJwt(jwt);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
isExternalLogoutAllowed.value = false;
systemName.value = "";
}
}
});
</script>

<style scoped>
Expand Down
Loading
Loading