Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6efc735
Add support for embedded form domains
jasonchung1871 Jul 2, 2025
63797b9
Add form embedding domain management and security
jasonchung1871 Jul 2, 2025
00a00d4
Add custom validators for requestId and domainId params
jasonchung1871 Jul 2, 2025
919aeef
Update embed domain routes and schema validation
jasonchung1871 Jul 3, 2025
819e82d
Add form embedding management feature
jasonchung1871 Jul 3, 2025
d509422
Refactor and enhance embedded forms domain management
jasonchung1871 Jul 8, 2025
156fec4
Refactor domain request review and revoke endpoints
jasonchung1871 Jul 9, 2025
8153529
Remove unused search method from FormEmbedDomainVw
jasonchung1871 Jul 9, 2025
d193a12
Add admin UI and backend support for form embedding
jasonchung1871 Jul 9, 2025
2aa4396
Remove unused variables and add embed constant
jasonchung1871 Jul 9, 2025
758c22c
Add i18n support for form embed features
jasonchung1871 Jul 9, 2025
92d810a
Add logging and error handling to embed security checks
jasonchung1871 Jul 9, 2025
e637c02
Merge branch 'main' into FORMS-2500-embedded-forms
jasonchung1871 Jul 9, 2025
65f50be
Add dynamic domain status codes for form embed
jasonchung1871 Jul 9, 2025
585e6a8
Merge branch 'FORMS-2500-embedded-forms' of https://github.com/jasonc…
jasonchung1871 Jul 9, 2025
07624d5
Merge branch 'main' into FORMS-2500-embedded-forms
jasonchung1871 Jul 9, 2025
b6a21bf
Set default embedPanel value to 1
jasonchung1871 Jul 9, 2025
783e036
Merge branch 'main' into FORMS-2500-embedded-forms
jasonchung1871 Jul 10, 2025
0d8c5bc
Merge branch 'main' into FORMS-2500-embedded-forms
jasonchung1871 Jul 10, 2025
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
483 changes: 483 additions & 0 deletions app/frontend/src/components/admin/AdminFormEmbed.vue

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/frontend/src/components/admin/AdminPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import AdminFormsTable from '~/components/admin/AdminFormsTable.vue';
import AdminUsersTable from '~/components/admin/AdminUsersTable.vue';
import AdminAPIsTable from '~/components/admin/AdminAPIsTable.vue';
import AdminFormEmbed from '~/components/admin/AdminFormEmbed.vue';
import Dashboard from '~/components/admin/Dashboard.vue';
import Developer from '~/components/admin/Developer.vue';
import FormComponentsProactiveHelp from '~/components/admin/FormComponentsProactiveHelp.vue';
Expand Down Expand Up @@ -39,6 +40,9 @@ watch(isRTL, () => {
$t('trans.adminPage.users')
}}</v-tab>
<v-tab value="apis" :lang="locale">{{ $t('trans.adminPage.apis') }}</v-tab>
<v-tab value="embed" :lang="locale">{{
$t('trans.adminPage.formEmbedding')
}}</v-tab>
<v-tab value="developer" :lang="locale">{{
$t('trans.adminPage.developer')
}}</v-tab>
Expand All @@ -61,6 +65,9 @@ watch(isRTL, () => {
<v-window-item value="apis">
<AdminAPIsTable />
</v-window-item>
<v-window-item value="embed">
<AdminFormEmbed />
</v-window-item>
<v-window-item value="developer">
<Developer />
</v-window-item>
Expand Down
5 changes: 5 additions & 0 deletions app/frontend/src/components/designer/FormViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getDisposition,
} from '~/utils/transformUtils';
import { FormPermissions, NotificationTypes } from '~/utils/constants';
import { initFormEmbed, isFormEmbedded } from '~/utils/embedUtils';

const { t, locale } = useI18n({ useScope: 'global' });

Expand Down Expand Up @@ -199,6 +200,10 @@ onMounted(async () => {
}
window.addEventListener('beforeunload', beforeWindowUnload);

if (isFormEmbedded()) {
initFormEmbed(properties.formId);
}

reRenderFormIo.value += 1;
});

Expand Down
339 changes: 339 additions & 0 deletions app/frontend/src/components/forms/manage/Embed.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';

import { useFormStore } from '~/store/form';
import { useNotificationStore } from '~/store/notification';
import { FormPermissions, NotificationTypes } from '~/utils/constants';
import { embedService } from '~/services';

const { t, locale } = useI18n({ useScope: 'global' });

const properties = defineProps({
formId: {
required: true,
type: String,
},
});

const newDomainForm = ref(null);
const loading = ref(false);
const submitting = ref(false);
const valid = ref(false);
const newDomain = ref('');
const domains = ref([]);
const domainStatuses = ref([]);
const domainHistoryMap = ref(new Map());

const formStore = useFormStore();
const notificationStore = useNotificationStore();

const { permissions, isRTL } = storeToRefs(formStore);

const headers = ref([
{
title: t('trans.formEmbed.domain'),
align: 'start',
key: 'domain',
},
{
title: t('trans.formEmbed.requestedAt'),
key: 'requestedAt',
},
{
title: t('trans.formEmbed.status'),
key: 'status',
},
{
title: t('trans.formEmbed.actions'),
value: 'actions',
align: 'end',
sortable: false,
},
]);

const canUpdate = computed(() => {
return permissions.value.includes(FormPermissions.FORM_UPDATE);
});

const items = computed(() =>
domains.value.map((x) => ({
...x,
history: domainHistoryMap.value.get(x.id) || [],
}))
);

const domainRules = ref([
(v) => {
const pattern =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](\.[a-zA-Z]{2,})+$/;
return pattern.test(v) || t('trans.formEmbed.domainRules');
},
]);

onMounted(() => {
fetchDomainStatuses();
fetchData();
});

async function fetchDomainStatuses() {
try {
const response = await embedService.getFormEmbedDomainStatusCodes(
properties.formId
);
domainStatuses.value = response.data;
} catch (error) {
notificationStore.addNotification({
text: t('trans.formEmbed.getStatusCodesErr'),
consoleError: t('trans.formEmbed.getStatusCodesConsErr', {
error: error,
}),
});
}
}

async function fetchData() {
try {
loading.value = true;

// Fetch allowed domains
const response = await embedService.listDomains(properties.formId);
domains.value = response.data;
} catch (error) {
notificationStore.addNotification({
text: t('trans.formEmbed.listDomainsErr'),
consoleError: t('trans.formEmbed.listDomainsConsErr', {
error: error,
}),
});
} finally {
loading.value = false;
}
}

async function requestDomain() {
if (!newDomainForm.value.validate()) return;

try {
submitting.value = true;

await embedService.requestDomain(properties.formId, {
domain: newDomain.value,
});

notificationStore.addNotification({
text: t('trans.formEmbed.requestDomainSuccess'),
...NotificationTypes.SUCCESS,
});

newDomain.value = '';
newDomainForm.value.resetValidation();
await fetchData();
} catch (error) {
notificationStore.addNotification({
text: t('trans.formEmbed.requestDomainErr'),
consoleError: t('trans.formEmbed.requestDomainConsErr', {
error: error,
}),
});
} finally {
submitting.value = false;
}
}

async function removeDomain(domain) {
try {
await embedService.removeDomain(properties.formId, domain.id);

notificationStore.addNotification({
text: t('trans.formEmbed.removeDomainSuccess'),
...NotificationTypes.SUCCESS,
});

await fetchData();
} catch (error) {
notificationStore.addNotification({
text: t('trans.formEmbed.removeDomainErr'),
consoleError: t('trans.formEmbed.removeDomainConsErr', {
error: error,
}),
});
}
}

function getStatusColour(status) {
// Define a default mapping that can be used before the API data is loaded
const defaultMapping = {
APPROVED: 'success',
DENIED: 'error',
PENDING: 'warning',
SUBMITTED: 'info',
};

// If we have statuses from the API, find the matching status
if (Array.isArray(domainStatuses.value) && domainStatuses.value.length > 0) {
// Find the status in the array
const statusObj = domainStatuses.value.find((s) => s.code === status);

// If found, map it to a color based on the code
if (statusObj) {
return defaultMapping[statusObj.code] || 'grey';
}
}

// Fall back to the default mapping if API data isn't available yet
return defaultMapping[status] || 'grey';
}

async function handleExpand(item, isExpanded, toggleExpand) {
if (!isExpanded(item)) {
try {
const history = await embedService.getDomainHistory(
properties.formId,
item.raw.id
);
domainHistoryMap.value.set(item.raw.id, history.data);
toggleExpand(item);
} catch (error) {
notificationStore.addNotification({
text: t('trans.formEmbed.getDomainHistoryErr'),
consoleError: t('trans.formEmbed.getDomainHistoryConsErr', {
error: error,
}),
});
} finally {
loading.value = false;
}
} else {
toggleExpand(item);
}
}
</script>

/* c8 ignore start */
<template>
<div :class="{ 'dir-rtl': isRTL }">
<div v-if="!canUpdate" class="mt-3 mb-6">
<v-icon class="mr-1" color="primary" icon="mdi:mdi-information"></v-icon>
<span :lang="locale" v-html="$t('trans.apiKey.formOwnerKeyAcess')"></span>
</div>
<h3 class="mt-3" :lang="locale">
{{ $t('trans.apiKey.disclaimer') }}
</h3>
<ul :class="isRTL ? 'mr-6' : null">
<li :lang="locale">{{ $t('trans.formEmbed.disclaimer') }}</li>
</ul>
<v-skeleton-loader :loading="loading" type="button" class="bgtrans">
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
show-expand
>
<template #item.status="{ item }">
<v-chip :color="getStatusColour(item.status)">
{{ item.status }}
</v-chip>
</template>
<template #item.actions="{ item }">
<span>
<v-tooltip location="bottom">
<template #activator="{ props }">
<v-btn
color="red"
class="mx-1"
icon
v-bind="props"
variant="text"
:title="$t('trans.formEmbed.delete')"
:disabled="!canUpdate"
@click="removeDomain(item)"
>
<v-icon icon="mdi:mdi-delete" />
</v-btn>
</template>
<span :lang="locale">{{ $t('trans.formEmbed.delete') }}</span>
</v-tooltip>
</span>
</template>
<template
#item.data-table-expand="{ internalItem, isExpanded, toggleExpand }"
>
<v-btn
:append-icon="
isExpanded(internalItem) ? 'mdi-chevron-up' : 'mdi-chevron-down'
"
class="text-none"
color="medium-emphasis"
size="small"
variant="text"
border
slim
@click="handleExpand(internalItem, isExpanded, toggleExpand)"
>
{{
isExpanded(internalItem)
? $t('trans.formDesigner.collapse')
: $t('trans.formEmbed.viewHistory')
}}
</v-btn>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="py-2">
<v-sheet rounded="lg" border>
<v-table density="compact">
<tbody class="bg-surface-light">
<tr>
<td>{{ $t('trans.formEmbed.previousStatus') }}</td>
<td>{{ $t('trans.formEmbed.newStatus') }}</td>
<td>{{ $t('trans.formEmbed.reason') }}</td>
<td>{{ $t('trans.formEmbed.createdBy') }}</td>
<td>{{ $t('trans.formEmbed.createdAt') }}</td>
</tr>
</tbody>
<tbody>
<tr
v-for="(historyItem, index) in item.history"
:key="index"
>
<td>{{ historyItem.previousStatus }}</td>
<td>{{ historyItem.newStatus }}</td>
<td>{{ historyItem.reason }}</td>
<td>{{ historyItem.statusChangedBy }}</td>
<td>{{ historyItem.statusChangedAt }}</td>
</tr>
</tbody>
</v-table>
</v-sheet>
</td>
</tr>
</template>
</v-data-table>
<v-form ref="newDomainForm" v-model="valid">
<v-text-field
v-model="newDomain"
:label="$t('trans.formEmbed.domain')"
placeholder="example.com"
:rules="[
(v) => !!v || $t('trans.formEmbed.emptyFieldRules'),
domainRules,
]"
:hint="$t('trans.formEmbed.newDomainHint')"
persistent-hint
required
/>
<v-btn
:disabled="!valid"
:loading="submitting"
class="mt-4"
@click="requestDomain"
>
{{ $t('trans.formEmbed.requestDomain') }}
</v-btn>
</v-form>
</v-skeleton-loader>
</div>
</template>
/* c8 ignore end */
Loading