diff --git a/EMERALD.md b/EMERALD.md index 89c052df..a78e2e42 100644 --- a/EMERALD.md +++ b/EMERALD.md @@ -23,6 +23,7 @@ Before running these commands you will need to create and populate configuration There is a folder for each environment, and within each folder it should contain the following files. +- ches.env - css.env - dashboard.env - database.env diff --git a/src/api/Keycloak/CssHelper.cs b/src/api/Keycloak/CssHelper.cs index 93c3606c..33ef1379 100644 --- a/src/api/Keycloak/CssHelper.cs +++ b/src/api/Keycloak/CssHelper.cs @@ -159,6 +159,7 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS. if (userRoles.Users.Length > 1) throw new NotAuthorizedException($"Keycloak has multiple users with the same username '{key}'"); if (user == null) { + _logger.LogDebug("User activation, New User: {username}:{key}", username, key); // Add the user to the database. user = new Entities.User(username, email, key) { @@ -170,9 +171,11 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS. LastLoginOn = DateTime.UtcNow, }; _userService.Add(user); + _userService.CommitTransaction(); } else if (user != null) { + _logger.LogDebug("User activation, Update User: {username}:{key}", username, key); // The user was created in HSB initially, but now the user has logged in and activated their account. user.Username = username; user.DisplayName = principal.GetDisplayName() ?? user.DisplayName; @@ -189,15 +192,15 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS. // Apply the preapproved roles to the user. var roles = await UpdateUserRolesAsync(key.ToString(), preapprovedRoles); _userService.Update(user); - return user; + _userService.CommitTransaction(); } } else { user.LastLoginOn = DateTime.UtcNow; _userService.Update(user); + _userService.CommitTransaction(); } - _userService.CommitTransaction(); return user; } diff --git a/src/dashboard/src/app/api/auth/authOptions.ts b/src/dashboard/src/app/api/auth/authOptions.ts index ebbd41a8..34b18c1c 100644 --- a/src/dashboard/src/app/api/auth/authOptions.ts +++ b/src/dashboard/src/app/api/auth/authOptions.ts @@ -62,8 +62,10 @@ export const authOptions: AuthOptions = { const aToken = token as any; if (trigger === 'update') { - token.roles = session.user.roles; - return token; + const refreshToken = await refreshAccessToken(token); + refreshToken.roles = session.user.roles; + refreshToken.decoded = jwtDecode(`${refreshToken.access_token}`) as any; + return refreshToken; } else { if (account && user) { token.access_token = account.access_token; diff --git a/src/dashboard/src/app/hsb/admin/users/page.tsx b/src/dashboard/src/app/hsb/admin/users/page.tsx index d2f1582d..07f98de1 100644 --- a/src/dashboard/src/app/hsb/admin/users/page.tsx +++ b/src/dashboard/src/app/hsb/admin/users/page.tsx @@ -18,6 +18,10 @@ import { EditUserRow } from './EditUserRow'; import { defaultUser } from './defaultUser'; import { validateUser } from './validateUser'; +/** + * Provides a page component which displays all users so that the admin can modify their permissions. + * @returns Component + */ export default function Page() { const state = useAuth(); const { isReady: isReadyUsers, users } = useAdminUsers({ includePermissions: true, init: true }); @@ -74,7 +78,7 @@ export default function Page() { }, []); const handleRemoveUserRow = React.useCallback((userKey: string) => { - setFormUsers((currentUsers) => currentUsers.filter(user => user.key !== userKey)); + setFormUsers((currentUsers) => currentUsers.filter((user) => user.key !== userKey)); }, []); const handleUpdate = React.useCallback(async () => { diff --git a/src/dashboard/src/app/login/page.tsx b/src/dashboard/src/app/login/page.tsx index 7e107fc2..90171c9e 100644 --- a/src/dashboard/src/app/login/page.tsx +++ b/src/dashboard/src/app/login/page.tsx @@ -1,10 +1,18 @@ 'use client'; import { Button } from '@/components'; +import { useAuth } from '@/hooks'; import { signIn } from 'next-auth/react'; -import styles from './Login.module.scss' +import { redirect } from 'next/navigation'; +import styles from './Login.module.scss'; export default function Page() { + const { isAuthorized } = useAuth(); + + // Need this because after activating a pre-authorized user, they get sent to the login page again for some reason. + // One downside to this is it is impossible to go to the login page when already authorized. + if (isAuthorized) redirect('/'); + return (
diff --git a/src/dashboard/src/app/page.tsx b/src/dashboard/src/app/page.tsx index e9871929..1372fbca 100644 --- a/src/dashboard/src/app/page.tsx +++ b/src/dashboard/src/app/page.tsx @@ -5,7 +5,7 @@ import { useAuth } from '@/hooks'; import { redirect } from 'next/navigation'; export default function Page() { - const { isClient, isHSB, isAuthorized } = useAuth(); + const { isClient, isHSB, isAuthorized, session } = useAuth(); // Redirect to default page for each type of user. if (isClient) redirect('/client/dashboard'); diff --git a/src/dashboard/src/app/welcome/Welcome.module.scss b/src/dashboard/src/app/welcome/Welcome.module.scss index 7aefb01a..24ee0e8f 100644 --- a/src/dashboard/src/app/welcome/Welcome.module.scss +++ b/src/dashboard/src/app/welcome/Welcome.module.scss @@ -39,9 +39,9 @@ font-weight: bold; font-style: italic; color: $bc-black; - + &:hover { - text-decoration: underline; + text-decoration: underline; } } } @@ -50,4 +50,23 @@ display: flex; flex-direction: column; justify-content: flex-end; +} + +.activate { + margin-top: 2rem; + padding: 1rem; + font-size: $font-size-18; + border-radius: 1rem; + background: #E8EAEC; + display: flex; + flex-direction: row; + + >div:first-child { + flex: 2; + } + + >div:nth-child(2) { + position: relative; + flex: 1; + } } \ No newline at end of file diff --git a/src/dashboard/src/app/welcome/page.tsx b/src/dashboard/src/app/welcome/page.tsx index ae8d4d3a..ccb101be 100644 --- a/src/dashboard/src/app/welcome/page.tsx +++ b/src/dashboard/src/app/welcome/page.tsx @@ -1,15 +1,44 @@ +'use client'; + +import { LoadingAnimation } from '@/components'; +import { useAuth } from '@/hooks'; +import { useAppStore } from '@/store'; +import { redirect } from 'next/navigation'; import styles from './Welcome.module.scss'; export default function Page() { + const { isAuthorized } = useAuth(); + const userinfo = useAppStore((state) => state.userinfo); + + // Need this because after activating a pre-authorized user so that it will redirect to their home page. + if (isAuthorized) redirect('/'); + return (

Request Access

-

Please email placeholder@gov.bc.ca to request access to your organization's dashboard

+

+ Please email placeholder@gov.bc.ca to request + access to your organization's dashboard +

+ {!userinfo && ( +
+
+ Activating account. If account has been pre-approved it will automatically apply roles + and redirect to home page. +
+
+ +
+
+ )} + {userinfo && !userinfo.roles.length && ( +
Your account has not be pre-approved.
+ )}

Welcome to the Storage Dashboard

-

Sign in to get insights into your organization's data storage and usage.

+

Request access to get insights into your organization's data storage and usage.

); diff --git a/src/dashboard/src/components/auth/AuthState.tsx b/src/dashboard/src/components/auth/AuthState.tsx index fa68283f..2e23f351 100644 --- a/src/dashboard/src/components/auth/AuthState.tsx +++ b/src/dashboard/src/components/auth/AuthState.tsx @@ -5,7 +5,7 @@ import { useAppStore } from '@/store'; import { keycloakSessionLogOut } from '@/utils'; import _ from 'lodash'; import { signIn, useSession } from 'next-auth/react'; -import { redirect } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import React from 'react'; import { FaSignInAlt, FaSignOutAlt } from 'react-icons/fa'; import { toast } from 'react-toastify'; @@ -16,39 +16,75 @@ export interface IAuthState { showName?: boolean; } +/** + * Activator class provides a thread-safe singleton. + */ +class Activator { + private activating: boolean = false; + + constructor() {} + + /** + * Only the first request to start will return true. + * @returns Whether to start activating. + */ + start() { + if (!this.activating) { + this.activating = true; + return true; + } + return false; + } + + getState() { + return this.activating; + } + + setState(state: boolean) { + this.activating = state; + return this.activating; + } +} + +let activator = new Activator(); + export const AuthState: React.FC = ({ showName }) => { const { userinfo: getUserinfo } = useApiUsers(); const { status, session } = useAuth(); const userinfo = useAppStore((state) => state.userinfo); const setUserinfo = useAppStore((state) => state.setUserinfo); const { update } = useSession(); - - // Only ask for userinfo once. - const [init, setInit] = React.useState(true); + const router = useRouter(); React.useEffect(() => { - if (init && !userinfo && status === 'authenticated' && session) { - setInit(false); - getUserinfo() - .then(async (res) => { - const userinfo = await res.json(); - setUserinfo(userinfo); - // The user activation process can automatically apply roles to a preapproved user. - const roles = _.uniq(userinfo.groups.concat(session.user.roles)); - if ( - !session.user.roles.length || - !userinfo.groups.every((group: string) => roles.includes(group)) - ) { - update({ ...session, user: { ...session.user, roles: roles } }); - redirect('/'); - } - }) - .catch((error: any) => { - toast.error('Failed to activate user. Try to login again'); - console.error('Failed to activate user', error); - }); + if (!userinfo && status === 'authenticated' && session) { + if (activator.start()) { + if (session.user.roles.length === 0) toast.info('Attempting to preapprove your account'); + getUserinfo() + .then(async (res) => { + const userinfo = await res.json(); + // The user activation process can automatically apply roles to a preapproved user. + const roles = _.uniq(userinfo.groups.concat(session.user.roles)); + if ( + !session.user.roles.length || + !userinfo.groups.every((group: string) => roles.includes(group)) + ) { + // Inform the session of the pre-authorized user role changes. + await update({ ...session, user: { ...session.user, roles: roles } }); + setUserinfo(userinfo); + router.push('/'); + } else { + setUserinfo(userinfo); + } + }) + .catch((error: any) => { + toast.error('Failed to activate user. Try to login again'); + console.error('Failed to activate user', error); + activator.setState(false); + }); + } } - }, [setUserinfo, status, getUserinfo, session, update, userinfo, init]); + }, [setUserinfo, status, getUserinfo, session, update, userinfo, router]); if (status === 'loading') return
loading...
; else if (status === 'authenticated') { diff --git a/src/dashboard/src/components/loadingAnimation/LoadingAnimation.module.scss b/src/dashboard/src/components/loadingAnimation/LoadingAnimation.module.scss index aad63f58..8d3a66aa 100644 --- a/src/dashboard/src/components/loadingAnimation/LoadingAnimation.module.scss +++ b/src/dashboard/src/components/loadingAnimation/LoadingAnimation.module.scss @@ -1,63 +1,72 @@ @import '@/styles/utils.scss'; .container { - height: 100%; - width: 100%; - background-color: rgba($white, 0.7); - position: absolute; - top: 0; - left: 0; - border-radius: 6px; - display: flex; - justify-content: center; - align-items: center; - z-index: 1; - backdrop-filter: blur(2px); + height: 100%; + width: 100%; + background-color: rgba($white, 0.7); + position: absolute; + top: 0; + left: 0; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + backdrop-filter: blur(2px); } .animationContainer { - height: 80px; - width: 80px; - display: flex; - justify-content: center; - align-items: flex-end; - gap: 0.2rem; - - div { - animation: loading 2s infinite ease-in-out; - height: 5px; - width: 5px; - border-radius: 2px; - background-color: $bc-black; - - &.bar1 { - - } - - &.bar2 { - animation-delay: 0.2s; - } - - &.bar3 { - animation-delay: 0.4s; - } - - &.bar4 { - animation-delay: 0.6s; - } - - &.bar5 { - animation-delay: 0.8s; - } - - &.bar6 { - animation-delay: 1s; - } + height: 80px; + width: 80px; + display: flex; + justify-content: center; + align-items: flex-end; + gap: 0.2rem; + + div { + animation: loading 2s infinite ease-in-out; + height: 5px; + width: 5px; + border-radius: 2px; + background-color: $bc-black; + + &.bar1 {} + + &.bar2 { + animation-delay: 0.2s; + } + + &.bar3 { + animation-delay: 0.4s; + } + + &.bar4 { + animation-delay: 0.6s; } + + &.bar5 { + animation-delay: 0.8s; + } + + &.bar6 { + animation-delay: 1s; + } + } } @keyframes loading { - 0% {height: 10px;} - 50% {height: 30px; background-color: rgba($bc-black, 0.5);} - 100% {height: 10px}; + 0% { + height: 10px; + } + + 50% { + height: 30px; + background-color: rgba($bc-black, 0.5); + } + + 100% { + height: 10px + } + + ; } \ No newline at end of file diff --git a/src/dashboard/src/store/useAppStore.ts b/src/dashboard/src/store/useAppStore.ts index 893dab84..073265be 100644 --- a/src/dashboard/src/store/useAppStore.ts +++ b/src/dashboard/src/store/useAppStore.ts @@ -13,7 +13,7 @@ import { create } from 'zustand'; export interface IAppStoreState { // User - userinfo?: IUserInfoModel; // TODO: Replace with interface. + userinfo?: IUserInfoModel; setUserinfo: (value: IUserInfoModel) => void; // Roles diff --git a/src/libs/dal/Extensions/ServiceCollectionExtensions.cs b/src/libs/dal/Extensions/ServiceCollectionExtensions.cs index a8eade83..73fe05bb 100644 --- a/src/libs/dal/Extensions/ServiceCollectionExtensions.cs +++ b/src/libs/dal/Extensions/ServiceCollectionExtensions.cs @@ -45,7 +45,7 @@ public static IServiceCollection AddHSBContext(this IServiceCollection services, sqlOptions.CommandTimeout((int)TimeSpan.FromMinutes(5).TotalSeconds); }); - if (options == null) + if (options == null && env.IsDevelopment()) { var debugLoggerFactory = LoggerFactory.Create(builder => { builder.AddDebug(); }); sql.UseLoggerFactory(debugLoggerFactory); diff --git a/src/libs/dal/Services/UserService.cs b/src/libs/dal/Services/UserService.cs index 9ccd38af..9f84adc5 100644 --- a/src/libs/dal/Services/UserService.cs +++ b/src/libs/dal/Services/UserService.cs @@ -106,6 +106,27 @@ public IEnumerable FindByEmail(string email, bool includePermissions) .FirstOrDefault(u => EF.Functions.Like(u.Username, username)); } + public override EntityEntry Add(User entity) + { + entity.GroupsManyToMany.ForEach((group) => + { + group.User = entity; + this.Context.Entry(group).State = EntityState.Added; + }); + entity.OrganizationsManyToMany.ForEach((organization) => + { + organization.User = entity; + this.Context.Entry(organization).State = EntityState.Added; + }); + entity.TenantsManyToMany.ForEach((tenant) => + { + tenant.User = entity; + this.Context.Entry(tenant).State = EntityState.Added; + }); + + return base.Add(entity); + } + public override EntityEntry Update(User entity) { var isSystemAdmin = Keycloak.Extensions.IdentityExtensions.HasClientRole(this.Principal, HSB.Keycloak.ClientRole.SystemAdministrator);