Skip to content

Commit

Permalink
Merge pull request #824 from visualize-admin/auth
Browse files Browse the repository at this point in the history
  • Loading branch information
ptbrowne authored Nov 7, 2022
2 parents e2f4842 + cda44e4 commit b32e5aa
Show file tree
Hide file tree
Showing 27 changed files with 988 additions and 1,822 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,42 @@ Those charts configurations are kept in the repository.
At the moment, the screenshots are made from charts using data from int.lindas.admin.ch as for some functionalities, we do not
yet have production data.
## Authentication
Authentication by eIAM through a Keycloak instance.
We use Next-auth to integrate our application with Keycloak.
See https://next-auth.js.org/providers/keycloak for documentation.
### Locally
The easiest way is to run Keycloak via Docker.
```
docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:19.0.3 start-dev
```

⚠️ After creating the container via the above command, if you stop it, restart the container via the docker UI so that you re-use the
same storage, otherwise you'll have to reconfigure Keycloak.

To configure Keycloak:

- Access the [Keycloak admin][keycloak-admin] (login, password: "admin", "admin")
- Create client application
- Via import: [Keycloak][keycloak-admin] > Clients > Import client
- Use the exported client `keycloak-visualize-client-dev.json`
- Manually: [Keycloak][keycloak-admin] > Clients > Create client
- id: "visualize"
- Choose OpenIDConnect
- In next slide, toggle "Client Authentication" on
- Configure redirect URI on client
- Root URL: `http://localhost:3000`
- Redirect URI: `/api/auth/callback/keycloak`
- Create a user
- Set a password to the user (in Credentials tab)
- Set environment variables in `.env.local`
- KEYCLOAK_ID: "visualize"
- KEYCLOAK_SECRET: From [Keycloak][keycloak-admin] > Clients > visualize > Credentials > Client secret
- KEYCLOAK_ISSUER: http://localhost:8080/realms/master

[keycloak-admin]: http://localhost:8080/admin/master/console/#/
3 changes: 3 additions & 0 deletions app/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import Flex from "@/components/flex";
import { LanguageMenu } from "@/components/language-menu";
import { SOURCE_OPTIONS } from "@/domain/datasource/constants";

import LoginMenu from "./login-menu";

const DEFAULT_HEADER_PROGRESS = 100;

export const useHeaderProgressContext = () => {
Expand Down Expand Up @@ -137,6 +139,7 @@ const MetadataMenu = ({ contentId }: { contentId?: string }) => {
}}
>
<LanguageMenu contentId={contentId} />
<LoginMenu />
</Flex>
);
};
Expand Down
72 changes: 72 additions & 0 deletions app/components/login-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Typography, Button, Box } from "@mui/material";
import { getProviders, signIn, signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useEffect, useState } from "react";

import { Awaited } from "@/domain/types";

type Providers = Awaited<ReturnType<typeof getProviders>>;

const useProviders = () => {
const [state, setState] = useState({
status: "loading",
data: undefined as Providers | undefined,
});
useEffect(() => {
const run = async () => {
const providers = await getProviders();
setState({ status: "loaded", data: providers });
};
run();
}, []);
return state;
};

function LoginMenu() {
const { data: session, status: sessionStatus } = useSession();
const { data: providers, status: providersStatus } = useProviders();
if (sessionStatus === "loading" || providersStatus === "loading") {
return null;
}
if (!providers || !Object.keys(providers).length) {
return null;
}
return (
<Box sx={{ alignItems: "center", display: "flex" }}>
{session ? (
<>
<Typography variant="body2">
Signed in as <Link href="/profile">{session.user?.name}</Link>{" "}
{session.user?.id}
{" - "}
</Typography>
<Button
variant="text"
color="primary"
size="small"
onClick={async () => await signOut()}
>
Sign out
</Button>
</>
) : (
<>
<Typography variant="body2">
Not signed in
{" - "}
</Typography>
<Button
variant="text"
color="primary"
size="small"
onClick={() => signIn("keycloak")}
>
Sign in
</Button>
</>
)}
</Box>
);
}

export default LoginMenu;
2 changes: 1 addition & 1 deletion app/configurator/config-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-redeclare */
import { fold } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/pipeable";
import { pipe } from "fp-ts/lib/function";
import * as t from "io-ts";

import { DataCubeMetadata } from "@/graphql/types";
Expand Down
5 changes: 5 additions & 0 deletions app/db/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default prisma;
113 changes: 66 additions & 47 deletions app/db/config.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
/**
* Server side methods to connect to the database
*/

import { Prisma, Config, User } from "@prisma/client";

import { Config as ConfigType } from "@/configurator";
import { migrateChartConfig } from "@/utils/chart-config/versioning";

import { createChartId } from "../utils/create-chart-id";

import { pool } from "./pg-pool";
import prisma from "./client";

type PublishedConfig = Omit<ConfigType, "activeField">;

/**
* Store data in the DB.
* Do not try to use on client-side! Use /api/config instead.
* If the user is logged, the chart is linked to the user.
*
* @param data Data to be stored as configuration
*/
export const createConfig = async (
data: $Unexpressable
data: Prisma.ConfigCreateInput["data"],
userId?: User["id"] | undefined
): Promise<{ key: string }> => {
const result = await pool.query<{ key: string }>(
`INSERT INTO config(key, data) VALUES ($1, $2) RETURNING key`,
[createChartId(), data]
);
const result = await prisma.config.create({
data: {
key: createChartId(),
data: data,
user_id: userId,
},
});

if (result.rows.length < 1) {
throw Error("No result after insert!");
}

return result.rows[0];
return result;
};

const migrateDataSet = (dataSet: string): string => {
Expand All @@ -33,51 +42,61 @@ const migrateDataSet = (dataSet: string): string => {
return dataSet;
};

type ChartJsonConfig = {
dataSet: string;
chartConfig: Prisma.JsonObject;
};

const parseDbConfig = (conf: Config) => {
const data = conf.data as ChartJsonConfig;
const migratedData = {
...data,
dataSet: migrateDataSet(data.dataSet),
chartConfig: migrateChartConfig(data.chartConfig),
} as PublishedConfig;
return {
...conf,
data: migratedData,
};
};

/**
* Get data from DB.
* Do not try to use on client-side! Use /api/config instead.
*
* @param key Get data from DB with this key
*/
export const getConfig = async (
key: string
): Promise<undefined | { key: string; data: $Unexpressable }> => {
const result = await pool.query<{ key: string; data: $Unexpressable }>(
`SELECT key, data FROM config WHERE key = $1 LIMIT 1`,
[key]
);

const config = result.rows[0];

if (config && config.data) {
return {
...config,
data: {
...config.data,
dataSet: migrateDataSet(config.data.dataSet),
chartConfig: migrateChartConfig(config.data.chartConfig),
},
};
export const getConfig = async (key: string) => {
const config = await prisma.config.findFirst({
where: {
key: key,
},
});

if (!config) {
return;
}

return config;
return parseDbConfig(config);
};

/**
* Get all keys from DB.
* Do not try to use on client-side! Use /api/config instead.
*
* @param key Get data from DB with this key
*/
export const getAllConfigs = async (): Promise<
{
key: string;
data: $Unexpressable;
}[]
> => {
const result = await pool.query<{ key: string; data: $Unexpressable }>(
`SELECT key,data FROM config ORDER BY created_at DESC`
);

return result.rows;
export const getAllConfigs = async () => {
const configs = await prisma.config.findMany();
return configs.map((c) => parseDbConfig(c));
};

/**
* Get config from a user.
*/
export const getUserConfigs = async (userId: number) => {
const configs = await prisma.config.findMany({
where: {
user_id: userId,
},
});
return configs.map((c) => parseDbConfig(c));
};

export type ParsedConfig = ReturnType<typeof parseDbConfig>;
25 changes: 25 additions & 0 deletions app/db/serialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SuperJSON from "superjson";
import { SuperJSONResult } from "superjson/dist/types";

export type Serialized<P> = P & {
_superjson?: ReturnType<typeof SuperJSON.serialize>["meta"];
};

export const serializeProps = <T extends unknown>(props: T) => {
const { json: sprops, meta } = SuperJSON.serialize(props);
if (meta) {
// @ts-ignore
sprops._superjson = meta;
}
return sprops as Serialized<typeof props>;
};

type Deserialized<T> = T extends Serialized<infer S> ? S : never;

export const deserializeProps = <T extends Serialized<unknown>>(sprops: T) => {
const { _superjson, ...props } = sprops;
return SuperJSON.deserialize({
json: props,
meta: _superjson,
} as unknown as SuperJSONResult) as Deserialized<T>;
};
49 changes: 49 additions & 0 deletions app/db/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import prisma from "./client";

export const findBySub = async (sub: string) => {
return prisma.user.findFirstOrThrow({
where: {
sub,
},
});
};

/**
* Ensures an authenticated user has an account
* on our side.
*
* - Uses the "sub" field from the JWT token to ensure
* uniqueness.
* - Updates the user name with what is found in the JWT token
*/
export const ensureUserFromSub = async (
sub: string,
name: string | undefined | null
) => {
const user = await prisma.user.findFirst({
where: {
sub,
},
});
if (user) {
if (user.name !== name) {
console.log(`Updating user name from auth provider info`);
await prisma.user.update({
where: {
id: user.id,
},
data: {
name: name,
},
});
}
return user;
} else {
const newUser = await prisma.user.create({
data: {
sub: sub,
},
});
return newUser;
}
};
3 changes: 3 additions & 0 deletions app/domain/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const GA_TRACKING_ID =
*/

export const DATABASE_URL = process.env.DATABASE_URL;
export const KEYCLOAK_ID = process.env.KEYCLOAK_ID;
export const KEYCLOAK_SECRET = process.env.KEYCLOAK_SECRET;
export const KEYCLOAK_ISSUER = process.env.KEYCLOAK_ISSUER;

/**
* Variables set at **BUILD TIME** through `NEXT_PUBLIC_*` variables. Available on the client and server.
Expand Down
Loading

1 comment on commit b32e5aa

@vercel
Copy link

@vercel vercel bot commented on b32e5aa Nov 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

visualization-tool – ./

visualization-tool-git-main-ixt1.vercel.app
visualization-tool-ixt1.vercel.app
visualization-tool-alpha.vercel.app

Please sign in to comment.