Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import { RouterView } from 'vue-router';
import Toast from '@/volt/Toast.vue';
import ConfirmDialog from '@/volt/ConfirmDialog.vue';
import ThemeToggle from '@/components/ThemeToggle.vue';
</script>

<template>
<ThemeToggle />
<Toast />
<ConfirmDialog />
<RouterView />
Expand Down
2 changes: 2 additions & 0 deletions src/assets/base.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import 'tailwindcss';
@import 'tailwindcss-primeui';
@plugin '@tailwindcss/typography';

@custom-variant dark (&:is(.dark &));
149 changes: 110 additions & 39 deletions src/components/DrawIODiagramEditor.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue';
import { onMounted, onBeforeUnmount, ref, useTemplateRef, computed } from 'vue';
import type { Diagram } from '@/oscal';
import type { Property } from '@/oscal';
import type { ThemeChangeDetail } from '@/composables/useTheme';

const frame = useTemplateRef('frame');
const DRAWIO_URL = 'https://embed.diagrams.net/?spin=0&proto=json';
const DRAWIO_ORIGIN = new URL(DRAWIO_URL).origin;

const frame = useTemplateRef<HTMLIFrameElement>('frame');
const xmlCache = new Map<string, string>();

const props = defineProps<{
diagram: Diagram;
}>();

const currentDiagram = ref<Diagram>(props.diagram);
const latestXml = ref<string>('');
const diagramId = computed(() => currentDiagram.value.uuid);

const emit = defineEmits({
saved(diagram: Diagram) {
Expand All @@ -19,9 +26,21 @@ const emit = defineEmits({

onMounted(() => {
window.addEventListener('message', onDrawIoMessage);
window.addEventListener('theme-change', onThemeChange as EventListener);
});

onBeforeUnmount(() => {
window.removeEventListener('message', onDrawIoMessage);
window.removeEventListener('theme-change', onThemeChange as EventListener);
if (diagramId.value) {
xmlCache.delete(diagramId.value);
}
});

function onDrawIoMessage(e: MessageEvent) {
if (e.source !== frame.value?.contentWindow || e.origin !== DRAWIO_ORIGIN) {
return;
}
const req = JSON.parse(e.data);

switch (req.event) {
Expand All @@ -32,8 +51,9 @@ function onDrawIoMessage(e: MessageEvent) {
exportXml(props.diagram);
break;
case 'autosave':
// Ignored for now
// exportXml();
if (req.xml) {
updateXmlState(req.xml);
}
break;
case 'load':
// Ignored for now
Expand All @@ -43,6 +63,9 @@ function onDrawIoMessage(e: MessageEvent) {
if (req.message.diagram != props.diagram.uuid) {
break;
}
if (req.xml) {
updateXmlState(req.xml);
}

const pngProp = {
ns: 'ccf',
Expand Down Expand Up @@ -112,51 +135,99 @@ function findExistingXml() {
});
}

function loadXml() {
const existingXml = findExistingXml();
let dark = false;
function isDarkModeEnabled(): boolean {
if (typeof document !== 'undefined') {
return document.documentElement.classList.contains('dark');
}
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
dark = true;
return true;
}
if (existingXml) {
frame.value?.contentWindow?.postMessage(
JSON.stringify({
action: 'load',
xml: atob(existingXml.value as string),
noExitBtn: 1,
autosave: 1,
title: currentDiagram.value.uuid,
dark: dark,
}),
'*',
);
return false;
}

function sendLoadMessage({
xml,
dark,
title,
encoded,
}: {
xml: string;
dark: boolean;
title?: string;
encoded?: boolean;
}) {
postToFrame({
action: 'load',
xml: encoded ? atob(xml) : xml,
noExitBtn: 1,
autosave: 1,
title: title ?? currentDiagram.value.uuid,
dark,
});
}

function updateXmlState(xml: string) {
latestXml.value = xml;
if (diagramId.value) {
xmlCache.set(diagramId.value, xml);
}
}

function loadXml() {
const existingXml = findExistingXml();
const dark = isDarkModeEnabled();
if (existingXml?.value) {
const decoded = atob(existingXml.value as string);
updateXmlState(decoded);
sendLoadMessage({
xml: decoded,
dark,
});
return;
}
updateXmlState('');
sendLoadMessage({ xml: '', dark });
}

function exportXml(diagram: Diagram, options?: { format?: string }) {
postToFrame({
action: 'export',
format: options?.format ?? `xmlpng`,
diagram: diagram.uuid,
});
}

function postToFrame(message: Record<string, unknown>) {
if (!frame.value?.contentWindow) return;
frame.value.contentWindow.postMessage(JSON.stringify(message), DRAWIO_ORIGIN);
}

frame.value?.contentWindow?.postMessage(
JSON.stringify({
action: 'load',
xml: ``,
noExitBtn: 1,
autosave: 1,
dark: dark,
}),
'*',
);
function resolveXmlForReload() {
const id = diagramId.value;
if (id && xmlCache.has(id)) {
return xmlCache.get(id) ?? '';
}
if (latestXml.value) {
return latestXml.value;
}
const stored = findExistingXml();
if (stored?.value) {
try {
return atob(stored.value as string);
} catch {
return '';
}
}
return '';
}

function exportXml(diagram: Diagram) {
frame.value?.contentWindow?.postMessage(
JSON.stringify({
action: 'export',
format: `png`,
diagram: diagram.uuid,
}),
'*',
);
function onThemeChange(event: CustomEvent<ThemeChangeDetail>) {
const isDark = event.detail.theme === 'dark';
const xml = resolveXmlForReload();
sendLoadMessage({ xml, dark: isDark });
}
</script>

Expand Down
44 changes: 44 additions & 0 deletions src/components/ThemeToggle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from '@/composables/useTheme';

const { theme, toggleTheme, useSystemValue } = useTheme();

const iconPath = computed(() => {
if (theme.value === 'dark') {
// Font Awesome style sun icon
return 'M256 152c-57.34 0-104 46.66-104 104s46.66 104 104 104s104-46.66 104-104s-46.66-104-104-104m0-88c13.25 0 24-10.75 24-24V24c0-13.25-10.75-24-24-24s-24 10.75-24 24v16c0 13.25 10.75 24 24 24m0 368c-13.25 0-24 10.75-24 24v16c0 13.25 10.75 24 24 24s24-10.75 24-24v-16c0-13.25-10.75-24-24-24m200-200h-16c-13.25 0-24 10.75-24 24s10.75 24 24 24h16c13.25 0 24-10.75 24-24s-10.75-24-24-24m-368 24c0-13.25-10.75-24-24-24H48c-13.25 0-24 10.75-24 24s10.75 24 24 24h16c13.25 0 24-10.75 24-24m282.5-141.1l11.3-11.3c9.37-9.37 9.37-24.57 0-33.94s-24.57-9.37-33.94 0l-11.3 11.3c-9.37 9.37-9.37 24.57 0 33.94s24.57 9.37 33.94 0m-226.5 226.5l-11.3 11.3c-9.37 9.37-9.37 24.57 0 33.94s24.57 9.37 33.94 0l11.3-11.3c9.37-9.38 9.37-24.58 0-33.95s-24.57-9.37-33.94.01m0-226.5c9.37-9.37 9.37-24.57 0-33.94l-11.3-11.3c-9.37-9.37-24.57-9.37-33.94 0s-9.37 24.57 0 33.94l11.3 11.3c9.37 9.37 24.57 9.37 33.94 0m226.5 226.5c-9.37 9.37-9.37 24.57 0 33.94l11.3 11.3c9.37 9.37 24.57 9.37 33.94 0s9.37-24.57 0-33.94l-11.3-11.3c-9.37-9.38-24.57-9.38-33.94 0';
}
// Font Awesome style moon icon
return 'M279.135 512c78.962 0 151.253-35.925 199.061-92.792c7.079-8.327-.639-20.748-11.367-18.223C291.054 443.25 184.313 326.844 184.313 192c0-77.649 38.621-146.196 97.221-187.374c9.206-6.4 5.382-20.713-5.68-22.493C271.798 .579 267.891 0 263.903 0C118.134 0 0 114.639 0 256s118.134 256 263.903 256c5.09 0 10.142-.174 15.232-.51';
});

const handleClick = () => {
toggleTheme();
};

const handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
useSystemValue();
};
</script>

<template>
<button
class="fixed bottom-5 right-5 z-50 h-12 w-12 rounded-full bg-white/80 dark:bg-slate-800/80 shadow-lg border border-gray-200 dark:border-slate-700 flex items-center justify-center hover:scale-105 transition-transform"
@click="handleClick"
@contextmenu="handleContextMenu"
type="button"
aria-label="Toggle theme"
title="Left click toggles theme. Right click matches system preference."
>
<svg
class="h-6 w-6 text-gray-700 dark:text-amber-300 transition-colors"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
>
<path :d="iconPath" />
</svg>
</button>
</template>
2 changes: 1 addition & 1 deletion src/components/users/UserCreateForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const emit = defineEmits<{

const toast = useToast();
const { data: createdUser, execute } = useDataApi<CCFUser>(
'/api/users',
'/api/admin/users',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down
2 changes: 1 addition & 1 deletion src/components/users/UserEditForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const user = reactive({ ...props.user });

const toast = useToast();
const { data: updatedUser, execute } = useDataApi<CCFUser>(
`/api/users/${user.id}`,
`/api/admin/users/${user.id}`,
{
method: 'PUT',
data: user,
Expand Down
66 changes: 66 additions & 0 deletions src/composables/useOIDC.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ref } from 'vue';
import { useConfigStore } from '@/stores/config';
import { useGuestApi } from '@/composables/axios';

export interface OIDCProvider {
name: string;
displayName: string;
enabled: boolean;
iconUrl?: string;
}

export function useOIDC() {
const configStore = useConfigStore();
const providers = ref<OIDCProvider[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);

const { execute: fetchProviders, data: providersData } = useGuestApi<
OIDCProvider[]
>('/api/auth/sso/providers', { method: 'GET' }, { immediate: false });

const loadProviders = async () => {
isLoading.value = true;
error.value = null;
try {
await fetchProviders();
const fetchedProviders = providersData.value ?? [];
providers.value = fetchedProviders.filter(
(provider) => provider.enabled !== false,
);
} catch (_error) {
console.error('Failed to load SSO providers:');
error.value = 'Failed to load SSO providers';
providers.value = [];
} finally {
isLoading.value = false;
}
};

const initiateLogin = async (providerName: string) => {
const provider = providers.value.find(
(p) => p.name === providerName && p.enabled !== false,
);
if (!provider) {
const unavailableMessage = 'Selected SSO provider is not available.';
error.value = unavailableMessage;
throw new Error(unavailableMessage);
}
let config;
try {
config = await configStore.getConfig();
} catch (err) {
error.value = 'Failed to initiate SSO login';
throw err instanceof Error ? err : new Error('SSO init failed');
}
window.location.href = `${config.API_URL}/api/auth/sso/${provider.name}`;
};

return {
providers,
isLoading,
error,
loadProviders,
initiateLogin,
};
}
Loading