| 1 | <template> |
| 2 | <n-form ref="formRef" :model="form" :rules label-placement="top"> |
| 3 | <n-form-item label="Display name" path="display_name"> |
| 4 | <n-input |
| 5 | v-model:value="form.display_name" |
| 6 | placeholder="e.g. Acme Production Shuffle" |
| 7 | :maxlength="128" |
| 8 | show-count |
| 9 | /> |
| 10 | </n-form-item> |
| 11 | |
| 12 | <!-- |
| 13 | Shuffle Org picker. Phase 3a: dropdown of orgs the deployment's |
| 14 | admin Bearer can see, populated from /api/notifications/shuffle/orgs. |
| 15 | Manual entry stays as a fallback for offline use, restricted |
| 16 | networks, or when an org is too new to appear in the listing yet. |
| 17 | --> |
| 18 | <n-form-item label="Shuffle org" path="shuffle_org_id"> |
| 19 | <div class="flex w-full flex-col gap-2"> |
| 20 | <n-select |
| 21 | v-model:value="form.shuffle_org_id" |
| 22 | :options="orgOptions" |
| 23 | :loading="loadingOrgs" |
| 24 | :disabled="manualEntry" |
| 25 | filterable |
| 26 | placeholder="Pick a Shuffle org" |
| 27 | @update:value="onOrgPicked" |
| 28 | /> |
| 29 | |
| 30 | <n-checkbox v-model:checked="manualEntry" size="small"> |
| 31 | Don't see your org? Enter the ID manually |
| 32 | </n-checkbox> |
| 33 | |
| 34 | <n-collapse-transition :show="manualEntry"> |
| 35 | <n-input |
| 36 | v-model:value="form.shuffle_org_id" |
| 37 | placeholder="6b6f65a4-d8f8-48ef-b02f-23a4a5f73e4a" |
| 38 | :maxlength="64" |
| 39 | /> |
| 40 | </n-collapse-transition> |
| 41 | </div> |
| 42 | <template v-if="!fieldErrors.shuffle_org_id" #feedback> |
| 43 | Sent as the |
| 44 | <code>Org-Id</code> |
| 45 | header on every dispatch — scopes the Shuffle call to the right org's authenticated apps. |
| 46 | </template> |
| 47 | </n-form-item> |
| 48 | |
| 49 | <n-form-item> |
| 50 | <n-checkbox v-model:checked="form.enabled">Enabled</n-checkbox> |
| 51 | </n-form-item> |
| 52 | |
| 53 | <div class="flex justify-end gap-2"> |
| 54 | <n-button @click="$emit('close')">Cancel</n-button> |
| 55 | <n-button type="primary" :loading="submitting" @click="submit"> |
| 56 | {{ editing ? "Save changes" : "Add integration" }} |
| 57 | </n-button> |
| 58 | </div> |
| 59 | </n-form> |
| 60 | </template> |
| 61 | |
| 62 | <script setup lang="ts"> |
| 63 | import type { FormInst, FormRules } from "naive-ui" |
| 64 | import type { ApiError } from "@/types/common" |
| 65 | import type { ShuffleIntegration, ShuffleIntegrationPayload, ShuffleOrg } from "@/types/notifications.d" |
| 66 | import { NButton, NCheckbox, NCollapseTransition, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui" |
| 67 | import { computed, onBeforeMount, reactive, ref } from "vue" |
| 68 | import Api from "@/api" |
| 69 | import { getApiErrorMessage } from "@/utils" |
| 70 | |
| 71 | const props = defineProps<{ |
| 72 | customerCode: string |
| 73 | editingIntegration: ShuffleIntegration | null |
| 74 | }>() |
| 75 | |
| 76 | const emit = defineEmits<{ |
| 77 | (e: "submitted"): void |
| 78 | (e: "close"): void |
| 79 | }>() |
| 80 | |
| 81 | const message = useMessage() |
| 82 | const formRef = ref<FormInst | null>(null) |
| 83 | const submitting = ref(false) |
| 84 | |
| 85 | const editing = computed(() => props.editingIntegration !== null) |
| 86 | type FeedbackField = "shuffle_org_id" |
| 87 | |
| 88 | const fieldErrors = reactive<Partial<Record<FeedbackField, string>>>({}) |
| 89 | |
| 90 | const form = reactive<ShuffleIntegrationPayload>({ |
| 91 | display_name: props.editingIntegration?.display_name ?? "", |
| 92 | shuffle_org_id: props.editingIntegration?.shuffle_org_id ?? "", |
| 93 | enabled: props.editingIntegration?.enabled ?? true |
| 94 | }) |
| 95 | |
| 96 | // Org-picker state. We default to dropdown mode; manual entry is a |
| 97 | // one-checkbox escape hatch for cases where the Shuffle listing call |
| 98 | // fails or the desired org doesn't appear in the list. |
| 99 | const orgs = ref<ShuffleOrg[]>([]) |
| 100 | const loadingOrgs = ref(false) |
| 101 | const manualEntry = ref(false) |
| 102 | |
| 103 | const orgOptions = computed(() => |
| 104 | orgs.value.map(o => { |
| 105 | // Show the name with a short Org-Id suffix so admins can disambiguate |
| 106 | // when two orgs share a display name. Sub-orgs get an extra hint so |
| 107 | // it's obvious which rows are children of the parent (typical Shuffle |
| 108 | // pattern: one parent org per MSP, one sub-org per customer). |
| 109 | const idHint = `(${o.id.slice(0, 8)}…)` |
| 110 | const subOrgHint = o.creator_org ? " · sub-org" : "" |
| 111 | return { |
| 112 | label: `${o.name} ${idHint}${subOrgHint}`, |
| 113 | value: o.id |
| 114 | } |
| 115 | }) |
| 116 | ) |
| 117 | |
| 118 | function clearFieldError(field: FeedbackField) { |
| 119 | delete fieldErrors[field] |
| 120 | } |
| 121 | |
| 122 | function createFieldError(field: FeedbackField, message: string) { |
| 123 | fieldErrors[field] = message |
| 124 | return new Error(message) |
| 125 | } |
| 126 | |
| 127 | async function loadOrgs() { |
| 128 | if (loadingOrgs.value) return |
| 129 | |
| 130 | loadingOrgs.value = true |
| 131 | |
| 132 | try { |
| 133 | const res = await Api.notifications.listShuffleOrgs() |
| 134 | if (res.data.success) { |
| 135 | orgs.value = res.data.orgs |
| 136 | // If we're editing and the existing org_id isn't in the list, |
| 137 | // fall through to manual entry so the form stays usable. |
| 138 | if (editing.value && form.shuffle_org_id && !orgs.value.some(o => o.id === form.shuffle_org_id)) { |
| 139 | manualEntry.value = true |
| 140 | } |
| 141 | } else { |
| 142 | message.warning(res.data.message || "Failed to load Shuffle orgs") |
| 143 | manualEntry.value = true |
| 144 | } |
| 145 | } catch (err) { |
| 146 | message.error(getApiErrorMessage(err as ApiError) || "Failed to load Shuffle orgs") |
| 147 | manualEntry.value = true |
| 148 | } finally { |
| 149 | loadingOrgs.value = false |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | function onOrgPicked(_orgId: string | null) { |
| 154 | // No-op for now — kept as a hook in case Phase 3b wants to chain |
| 155 | // the picker into automatic display-name population. |
| 156 | } |
| 157 | |
| 158 | const rules: FormRules = { |
| 159 | display_name: { required: true, message: "Name is required", trigger: ["input", "blur"] }, |
| 160 | shuffle_org_id: { |
| 161 | required: true, |
| 162 | validator: (_rule, value: string | null) => { |
| 163 | if (!value) return createFieldError("shuffle_org_id", "Pick a Shuffle org or enter an Org-Id manually") |
| 164 | clearFieldError("shuffle_org_id") |
| 165 | return true |
| 166 | }, |
| 167 | trigger: ["input", "change", "blur"] |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | async function submit() { |
| 172 | try { |
| 173 | await formRef.value?.validate() |
| 174 | } catch { |
| 175 | return |
| 176 | } |
| 177 | |
| 178 | submitting.value = true |
| 179 | try { |
| 180 | const res = props.editingIntegration |
| 181 | ? await Api.notifications.updateShuffleIntegration(props.customerCode, props.editingIntegration.id, form) |
| 182 | : await Api.notifications.createShuffleIntegration(props.customerCode, form) |
| 183 | |
| 184 | if (res.data.success) { |
| 185 | message.success(editing.value ? "Integration updated" : "Integration added") |
| 186 | emit("submitted") |
| 187 | } else { |
| 188 | message.warning(res.data.message || "Failed to save integration") |
| 189 | } |
| 190 | } catch (err: unknown) { |
| 191 | message.error(getApiErrorMessage(err as never) || "Failed to save integration") |
| 192 | } finally { |
| 193 | submitting.value = false |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | onBeforeMount(() => { |
| 198 | loadOrgs() |
| 199 | }) |
| 200 | </script> |