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);