Merge pull request #13 from sailpoint-oss/added-report-form

Added pagination report and example source description update form
This commit is contained in:
Luke Hagar
2024-03-05 09:25:02 -05:00
committed by GitHub
25 changed files with 635 additions and 83 deletions

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IdentityNow Admin Console</title>
<title>IdentityNow Starter Application</title>
%sveltekit.head%
</head>
<body data-theme="wintry">

View File

@@ -16,23 +16,33 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.hasIdnSession = hasIdnSession;
if (hasSession) {
event.locals.session = getSession(event.cookies);
if(hasIdnSession){
event.locals.idnSession = await getToken(event.cookies);
const lastToken = lastCheckedToken(event.cookies);
const tokenDetails = getTokenDetails(event.cookies);
if (tokenDetails && lastToken != '' && lastToken === event.locals.idnSession.access_token ) {
event.locals.tokenDetails = tokenDetails
} else {
event.locals.tokenDetails = await checkToken(
event.locals.session.baseUrl,
event.locals.idnSession.access_token
);
event.cookies.set('tokenDetails', JSON.stringify(event.locals.tokenDetails), {
path: '/',
httpOnly: false,
secure: false
});
}}
if (hasIdnSession) {
const session = await getToken(event.cookies);
if (!session) {
event.locals.hasIdnSession = false;
event.locals.idnSession = undefined;
} else {
event.locals.idnSession = session;
const lastToken = lastCheckedToken(event.cookies);
const tokenDetails = getTokenDetails(event.cookies);
if (tokenDetails && lastToken != '' && lastToken === event.locals.idnSession.access_token) {
event.locals.tokenDetails = tokenDetails;
} else {
const tempTokenDetails = await checkToken(
event.locals.session.baseUrl,
event.locals.idnSession.access_token
);
if (tempTokenDetails) {
event.locals.tokenDetails = tempTokenDetails;
event.cookies.set('tokenDetails', JSON.stringify(event.locals.tokenDetails), {
path: '/',
httpOnly: false,
secure: false
});
}
}
}
}
}
if (event.url.pathname.startsWith('/home') || event.url.pathname.startsWith('/api')) {

View File

@@ -6,29 +6,35 @@
export let onPageChange: (e: CustomEvent<number>) => void;
export let onAmountChange: (e: CustomEvent<number>) => void;
export let onGo: (e: KeyboardEvent | MouseEvent) => void;
export let filters: string = '';
export let sorters: string = '';
export let filters: string | undefined = undefined;
export let sorters: string | undefined = undefined;
</script>
<div class=" p-4 flex flex-row flex-wrap justify-between gap-4">
<div class="flex flex-row gap-1">
<input
on:keydown={onGo}
bind:value={filters}
class="input"
title="Filter"
type="text"
placeholder="Filter"
/>
<input
on:keydown={onGo}
bind:value={sorters}
class="input"
title="Sorter"
type="text"
placeholder="Sorter"
/>
<button on:click={onGo} class="btn variant-filled-primary !text-white"> Go </button>
{#if filters !== undefined}
<input
on:keydown={onGo}
bind:value={filters}
class="input"
title="Filter"
type="text"
placeholder="Filter"
/>
{/if}
{#if sorters !== undefined}
<input
on:keydown={onGo}
bind:value={sorters}
class="input"
title="Sorter"
type="text"
placeholder="Sorter"
/>
{/if}
{#if filters !== undefined || sorters !== undefined}
<button on:click={onGo} class="btn variant-filled-primary !text-white"> Go </button>
{/if}
</div>
<p class="my-auto">Total Count: {totalCount}</p>
<Paginator

View File

@@ -7,7 +7,7 @@ export function formatDate(date: string | null | undefined) {
}
export function getLimit(url: URL) {
return url.searchParams.get('limit') || '250';
return url.searchParams.get('limit') || '5';
}
export function getFilters(url: URL) {
@@ -71,10 +71,10 @@ export function createOnGo(params: PaginationParams, path: string) {
if (e.type !== 'click' && (e as KeyboardEvent).key !== 'Enter') return;
const urlParams = new URLSearchParams();
urlParams.set('page', params.page);
urlParams.set('limit', params.limit);
urlParams.set('sorters', params.sorters);
urlParams.set('filters', params.filters);
if (params.page != '') urlParams.set('page', params.page);
if (params.limit != '') urlParams.set('limit', params.limit);
if (params.sorters != '') urlParams.set('sorters', params.sorters);
if (params.filters != '') urlParams.set('filters', params.filters);
console.log(`${path}?${urlParams.toString()}`);

View File

@@ -1,5 +1,4 @@
import HomeSvg from '$lib/Components/SVGs/HomeSVG.svelte';
import MessagesSvg from '$lib/Components/SVGs/MessagesSVG.svelte';
import ReportsSvg from '$lib/Components/SVGs/ReportsSVG.svelte';
export const navigation = [
@@ -13,10 +12,20 @@ export const navigation = [
icon: HomeSvg
},
{
url: '/home/Example Pages',
name: 'Reports',
url: '/home/example-pages',
name: 'Example Pages',
description: 'a list of example pages showcasing how to implement the IdentityNow SDK.',
icon: ReportsSvg
},
{
url: '/home/example-form',
name: 'Example Form',
description: 'A form example using the IdentityNow SDK.'
},
{
url: '/home/form-integration',
name: 'SailPoint Form Integration',
description: 'A form example using the IdentityNow SDK.'
}
]
}

View File

@@ -73,7 +73,7 @@ export function setTokenDetails(cookies: Cookies, tokenDetails: TokenDetails) {
});
}
export async function checkToken(apiUrl: string, token: string): Promise<TokenDetails> {
export async function checkToken(apiUrl: string, token: string): Promise<TokenDetails | undefined> {
const body = 'token=' + token;
const url = `${apiUrl}/oauth/check_token/`;
const response = await axios.post(url, body).catch(function (err) {
@@ -85,15 +85,19 @@ export async function checkToken(apiUrl: string, token: string): Promise<TokenDe
}
return undefined;
});
// if (response) {
// console.log(response.data);
// }
const tokenDetails = response!.data;
if (!response) {
return undefined;
}
const tokenDetails = response.data;
return tokenDetails;
}
export async function refreshToken(apiUrl: string, refreshToken: string): Promise<IdnSession> {
export async function refreshToken(
apiUrl: string,
refreshToken: string
): Promise<IdnSession | undefined> {
const url = `${apiUrl}/oauth/token?grant_type=refresh_token&client_id=sailpoint-cli&refresh_token=${refreshToken}`;
const response = await axios.post(url).catch(function (err) {
if (err.response) {
@@ -104,10 +108,12 @@ export async function refreshToken(apiUrl: string, refreshToken: string): Promis
}
return undefined;
});
// if (response) {
// console.log(response.data)
// }
const idnSession: IdnSession = response!.data as IdnSession;
if (!response) {
return undefined;
}
const idnSession: IdnSession = response.data as IdnSession;
return idnSession;
}
@@ -147,7 +153,7 @@ export function getSession(cookies: Cookies): Session {
return JSON.parse(sessionString) as Session;
}
export async function getToken(cookies: Cookies): Promise<IdnSession> {
export async function getToken(cookies: Cookies): Promise<IdnSession | undefined> {
const sessionString = cookies.get('session');
const idnSessionString = cookies.get('idnSession');
@@ -171,12 +177,17 @@ export async function getToken(cookies: Cookies): Promise<IdnSession> {
if (isJwtExpired(idnSession.access_token)) {
console.log('Refreshing IdnSession token...');
const newSession = await refreshToken(session.baseUrl, idnSession.refresh_token);
cookies.set('idnSession', JSON.stringify(newSession), {
path: '/',
httpOnly: false,
secure: false
});
return Promise.resolve(newSession);
if (newSession) {
cookies.set('idnSession', JSON.stringify(newSession), {
path: '/',
httpOnly: false,
secure: false
});
return Promise.resolve(newSession);
} else {
console.log('IdnSession token is expired');
return Promise.resolve(undefined);
}
} else {
console.log('IdnSession token is good');
return Promise.resolve(idnSession);

View File

@@ -177,7 +177,7 @@
<!-- <svelte:fragment slot="sidebarRight">Sidebar Right</svelte:fragment> -->
<!-- <svelte:fragment slot="pageHeader">Page Header</svelte:fragment> -->
<!-- Router Slot -->
<div class="flex flex-col">
<div class="flex flex-col h-full">
{#if crumbs.length > 0}
<div class="pl-2 pt-2 pr-2">
<ol class="breadcrumb card p-2">

View File

@@ -0,0 +1,53 @@
// src/routes/api/post.ts
import { createConfiguration } from '$lib/sailpoint/sdk.js';
import { error, json } from '@sveltejs/kit';
import {
CustomFormsBetaApi,
type CustomFormsBetaApiCreateFormInstanceRequest
} from 'sailpoint-api-client';
export const POST = async ({ request, locals }) => {
const config = createConfiguration(locals.session!.baseUrl, locals.idnSession!.access_token);
const api = new CustomFormsBetaApi(config);
const { formDefinitionId, formInput } = await request.json();
if (!formDefinitionId || !formInput) {
error(400, 'formDefinitionId and formInput are required');
}
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + 1);
const formInstance: CustomFormsBetaApiCreateFormInstanceRequest = {
body: {
createdBy: {
id: 'BOOYAH',
type: 'SOURCE'
},
expire: expireDate.toISOString(),
formDefinitionId: formDefinitionId,
formInput: {
...formInput
},
recipients: [
{
id: locals.idnSession?.identity_id,
type: 'IDENTITY'
}
],
standAloneForm: true,
state: 'ASSIGNED',
ttl: 1571827560
}
};
const formInstanceResp = await api.createFormInstance(formInstance);
// Process the request body and perform any necessary operations
// ...
// Return the response
return json({ formDefinitionId, formInput, formInstanceResp: formInstanceResp.data });
};

View File

@@ -1,18 +1,11 @@
<script lang="ts">
import ResourceLinksCard from '$lib/Components/HomepageCards/ResourceLinksCard.svelte';
import StatusCard from '$lib/Components/HomepageCards/StatusCard.svelte';
import SupportLinksCard from '$lib/Components/HomepageCards/SupportLinksCard.svelte';
import TenantLinksCard from '$lib/Components/HomepageCards/TenantLinksCard.svelte';
<div class="grid place-content-center h-full">
<p class="text-center px-60">
This starter application is meant to be an example of how you can build on top of the
IdentityNow UI development kit to build your own applications and tools for IdentityNow
export let data;
console.log(data);
</script>
<br />
<div class="flex flex-col gap-2 grow">
<div class="flex flex-row flex-wrap gap-2 grow">
<StatusCard />
<TenantLinksCard tenantUrl={data.session?.tenantUrl} />
<ResourceLinksCard />
<SupportLinksCard />
</div>
On the left hand side you will see some example pages showcasing some different kinds of pages
you could build. Each page is meant to be a starting point for you to build your own pages.
</p>
</div>

View File

@@ -0,0 +1,51 @@
import { createConfiguration } from '$lib/sailpoint/sdk';
import {
Paginator,
SourcesApi,
type Source,
type SourcesApiUpdateSourceRequest
} from 'sailpoint-api-client';
import type { Actions } from './$types';
export const actions = {
default: async ({ locals, request }) => {
const data = await request.formData();
console.log('default action');
console.log('data', data);
const config = createConfiguration(locals.session!.baseUrl, locals.idnSession!.access_token);
const api = new SourcesApi(config);
const source = JSON.parse(data.get('source')?.toString() || '{}');
const updatedDescription = data.get('updatedDescription')?.toString();
const params: SourcesApiUpdateSourceRequest = {
id: source.id,
jsonPatchOperation: [{ op: 'replace', path: '/description', value: updatedDescription }]
};
const resp = await api.updateSource(params);
if (resp.status !== 200) {
return { status: 'error', error: resp.statusText };
}
return { status: 'success' };
}
} satisfies Actions;
export const load = async ({ locals }) => {
const config = createConfiguration(locals.session!.baseUrl, locals.idnSession!.access_token);
const api = new SourcesApi(config);
const sourceResp = Paginator.paginate(api, api.listSources, { limit: 1000 });
const sources = new Promise<Source[]>((resolve) => {
sourceResp.then((response) => {
resolve(response.data);
});
});
return { sources };
};

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Progress from '$lib/Components/Progress.svelte';
import { onMount } from 'svelte';
import type { Writable } from 'svelte/store';
import { localStorageStore } from '@skeletonlabs/skeleton';
export let data;
const selectedSource: Writable<any> = localStorageStore('selectedSource', undefined);
const updatedDescription: Writable<string> = localStorageStore('updatedDescription', '');
onMount(async () => {
const sources = await data.sources;
if (sources.length > 0) {
$selectedSource = JSON.stringify(sources[0]);
$updatedDescription = sources[0].description || '';
}
});
const handleChange = (e: any) => {
$updatedDescription = JSON.parse(e.target.value).description || '';
};
$: console.log($selectedSource);
</script>
<div class="flex justify-center flex-col align-middle gap-2">
<div class="card p-4">
<p class="text-2xl text-center">Example Form</p>
</div>
<form method="POST" class="card p-4">
<p class="text-2xl text-center">Update Source Description</p>
<div class="flex flex-col gap-4">
{#await data.sources}
<div class="flex flex-row justify-center">
<Progress width="w-[100px]" />
</div>
{:then sources}
<label>
<span>Sources:</span>
<select
on:change={handleChange}
name="source"
placeholder="Select a source"
bind:value={$selectedSource}
class="select"
>
<option hidden disabled>Select a source</option>
{#each sources as source}
{@const sourceString = JSON.stringify(source)}
<option value={sourceString} selected={$selectedSource === sourceString}>
{source.name} - {source.type}
</option>
{/each}
</select>
</label>
<label>
<span>Description:</span>
<textarea name="updatedDescription" class="textarea" bind:value={$updatedDescription} />
</label>
{/await}
<button type="submit" class="btn variant-filled">Submit</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,44 @@
import { getLimit, getPage, getSorters } from '$lib/Utils.js';
import { createConfiguration } from '$lib/sailpoint/sdk.js';
import type { IdentityDocument, Search, SearchApiSearchPostRequest } from 'sailpoint-api-client';
import { SearchApi } from 'sailpoint-api-client';
export const load = async ({ url, locals }) => {
const config = createConfiguration(locals.session!.baseUrl, locals.idnSession!.access_token);
const api = new SearchApi(config);
const page = getPage(url);
const limit = getLimit(url);
const sorters = getSorters(url);
const search: Search = {
indices: ['identities'],
query: {
query: `*`
},
sort: sorters !== '' ? [sorters] : ['name']
};
const requestParams: SearchApiSearchPostRequest = {
search,
offset: Number(page) * Number(limit),
limit: Number(limit),
count: true
};
const reportResp = api.searchPost(requestParams);
const totalCount = new Promise<number>((resolve) => {
reportResp.then((response) => {
resolve(response.headers['x-total-count']);
});
});
const reportData = new Promise<IdentityDocument[]>((resolve) => {
reportResp.then((response) => {
resolve(response.data);
});
});
return { reportData, totalCount, params: { page, limit, sorters } };
};

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import Paginator from '$lib/Components/Paginator.svelte';
import Progress from '$lib/Components/Progress.svelte';
import {
TriggerCodeModal,
createOnAmountChange,
createOnGo,
createOnPageChange,
formatDate
} from '$lib/Utils.js';
import { getModalStore } from '@skeletonlabs/skeleton';
export let data;
const modalStore = getModalStore();
$: onPageChange = createOnPageChange(
{ ...data.params, filters: '', sorters },
'/home/reports/list-of-identities'
);
$: onAmountChange = createOnAmountChange(
{ ...data.params, filters: '', sorters },
'/home/reports/list-of-identities'
);
$: onGo = createOnGo(
{ ...data.params, filters: '', sorters },
'/home/reports/list-of-identities'
);
let sorters = data.params.sorters || '';
</script>
<div class="flex justify-center flex-col align-middle gap-2">
<div class="card p-4">
<p class="text-2xl text-center">List of all identities</p>
</div>
{#await data.reportData}
<div class="grid h-full place-content-center p-8">
<Progress width="w-[100px]" />
</div>
{:then reportData}
{#await data.totalCount then totalCount}
{#if totalCount > 250 || Number(data.params.limit) < totalCount}
<div class="card p-4">
<Paginator
{onAmountChange}
{onGo}
{onPageChange}
settings={{
page: Number(data.params.page),
limit: Number(data.params.limit),
size: totalCount,
amounts: [5, 10, 50, 100, 250]
}}
bind:sorters
{totalCount}
/>
</div>
{/if}
{/await}
{#if reportData.length === 0}
<div class="card p-4">
<p class=" text-center text-success-500">No inactive identities with access found</p>
</div>
{:else}
<div class="table-container">
<table class="table">
<thead class="table-head">
<th> Name </th>
<th> DisplayName </th>
<th> Sources </th>
<th> Created </th>
<th> Modified </th>
<th> Access Count </th>
<th> Entitlement Count </th>
<th> Role Count </th>
<th></th>
</thead>
<tbody class="table-body">
{#each reportData as identity}
<tr>
<td>
{identity.name}
</td>
<td>
{identity.displayName}
</td>
<td>
{identity.accounts?.map((account) => account.source?.name).join(', ')}
</td>
<td>
{formatDate(identity.created)}
</td>
<td>
{formatDate(identity.modified)}
</td>
<td>
{identity.accessCount}
</td>
<td>
{identity.entitlementCount}
</td>
<td>
{identity.roleCount}
</td>
<td>
<div class="flex flex-col justify-center gap-1">
<button
on:click={() => TriggerCodeModal(identity, modalStore)}
class="btn btn-sm variant-filled-primary text-sm !text-white"
>
View
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/await}
</div>

View File

@@ -1,23 +1,28 @@
export const reports = [
{
url: '/home/reports/inactive-identities-with-access',
url: '/home/example-pages/list-of-identities',
name: 'List of Identities',
description: 'This report will show all identities in the system'
},
{
url: '/home/example-pages/inactive-identities-with-access',
name: 'Inactive Identities With Access',
description:
'This report will show all identities that are inactive but still have access in sources'
},
{
url: '/home/reports/missing-cloud-life-cycle-state',
url: '/home/example-pages/missing-cloud-life-cycle-state',
name: 'Missing Cloud Life Cycle State',
description: 'This report will show all identities that are missing a cloud life cycle state'
},
{
url: '/home/reports/source-owner-configured',
url: '/home/example-pages/source-owner-configured',
name: 'Source Owner Configured',
description: 'This report will show all sources and their configured owners'
},
{
url: '/home/reports/source-aggregations',
url: '/home/example-pages/source-aggregations',
name: 'Source Aggregations',
description: 'This report will show all sources and their most recent aggregation events'
}

View File

@@ -0,0 +1,19 @@
import { createConfiguration } from '$lib/sailpoint/sdk.js';
import {
CustomFormsBetaApi,
type ExportFormDefinitionsByTenant200ResponseInnerBeta
} from 'sailpoint-api-client';
export const load = async ({ locals }) => {
const config = createConfiguration(locals.session!.baseUrl, locals.idnSession!.access_token);
const api = new CustomFormsBetaApi(config);
const formsResp = api.exportFormDefinitionsByTenant({});
const forms = new Promise<ExportFormDefinitionsByTenant200ResponseInnerBeta[]>((resolve) => {
formsResp.then((response) => {
resolve(response.data);
});
});
return { forms };
};

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import type { ExportFormDefinitionsByTenant200ResponseInnerBeta } from 'sailpoint-api-client';
import Progress from '$lib/Components/Progress.svelte';
export let data;
console.log(data);
let selectedForm: ExportFormDefinitionsByTenant200ResponseInnerBeta;
let dialog: HTMLDialogElement;
let inputs = {};
let loading = false;
let formUrl: string;
let conditions = new Map();
function parseFormConditions(conditions) {
let parsedConditionals = new Map();
console.log(conditions);
for (const condition of conditions || []) {
for (const rule of condition.rules) {
console.log(rule);
const temp = parsedConditionals.get(rule.source) || [];
parsedConditionals.set(rule.source, Array.from(new Set([...temp, rule.value])));
}
}
console.log(parsedConditionals);
return parsedConditionals;
}
$: if (selectedForm) {
conditions = parseFormConditions(selectedForm.object?.formConditions);
}
</script>
<dialog class="card p-8" bind:this={dialog}>
<div class="flex flex-col gap-4">
{#if selectedForm}
<p id="name" class="text-center">
Name: {selectedForm.object?.name}
</p>
<p>
Description: <br />
{selectedForm.object?.description}
</p>
<div>
<p>Form Inputs:</p>
{#if selectedForm.object?.formInput}
{#each selectedForm.object?.formInput as input}
{#if conditions.get(input.label)}
<label class="label" for={input.label}>
<span>{input.label}</span>
<select class="input" id={input.label} bind:value={inputs[input.label]}>
{#each conditions.get(input.label) as condition}
<option value={condition}>{condition}</option>
{/each}
</select>
</label>
{:else}
<label class="label" for={input.label}>
<span>{input.label}</span>
<input
class="input"
id={input.label}
bind:value={inputs[input.label]}
type="text"
/>
</label>
{/if}
{/each}
{/if}
</div>
{/if}
{#if loading}
<div class="flex flex-row justify-center">
<Progress width="w-[80px]" />
</div>
{:else}
{#if formUrl}
<a class="btn variant-filled-secondary" href={formUrl} target="_blank" rel="noreferrer">
Open Form
</a>
{/if}
<button
class="btn variant-filled-primary"
on:click={async () => {
loading = true;
console.log(inputs);
const formResp = await fetch('/api/sailpoint/form/create-instance', {
method: 'POST',
body: JSON.stringify({ formDefinitionId: selectedForm.object?.id, formInput: inputs })
});
const respData = await formResp.json();
console.log(respData);
formUrl = respData.formInstanceResp.standAloneFormUrl;
loading = false;
}}
>
{#if formUrl}
Refresh form link
{:else}
Create form link
{/if}
</button>
{/if}
<button
class="btn variant-filled-warning"
on:click={() => {
dialog.close();
}}
>
Close
</button>
</div>
</dialog>
<div>
{#await data.forms}
<div class="flex flex-row justify-center">
<Progress width="w-[80px]" />
</div>
{:then forms}
<div class="flex flex-row">
{#each forms as form}
<div class="card flex flex-col p-4 gap-4">
<p id="name" class="text-center">
Name: {form.object?.name}
</p>
<p>
ID: {form.object?.id}
</p>
<p>
Description: <br />
{form.object?.description}
</p>
<div>
<p>Form Inputs:</p>
{#if form.object?.formInput}
{#each form.object?.formInput as input}
<p class="">
{input.label}
</p>
{/each}
{/if}
</div>
<button
class="btn variant-filled-primary"
on:click={() => {
selectedForm = form;
dialog.showModal();
}}
>
Assign form
</button>
</div>
{/each}
</div>
{/await}
</div>