Skip to content

Commit 777c194

Browse files
Merge branch 'dev' of https://github.com/KelvinTegelaar/CIPP into dev
2 parents c6ea22e + c058808 commit 777c194

8 files changed

Lines changed: 281 additions & 150 deletions

File tree

.github/copilot-instructions.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## Commit messages
2+
3+
Generate all commit messages in **Conventional Commits** format:
4+
5+
```
6+
<type>(<optional scope>): <description>
7+
8+
[optional body]
9+
10+
[optional footer(s)]
11+
```
12+
13+
**Rules:**
14+
15+
- **Type** is required and must be one of:
16+
- `feat` — a new feature
17+
- `fix` — a bug fix
18+
- `docs` — documentation only
19+
- `style` — formatting, whitespace, no code-behavior change
20+
- `refactor` — code change that neither fixes a bug nor adds a feature
21+
- `perf` — a performance improvement
22+
- `test` — adding or correcting tests
23+
- `build` — build system or dependency changes
24+
- `ci` — CI/CD configuration changes
25+
- `chore` — routine maintenance, no production code change
26+
- `revert` — reverts a previous commit
27+
- **Scope** is optional and given in parentheses after the type (e.g. `feat(identity):`). Use a short, lowercase area name when it adds clarity.
28+
- **Description** is a short, imperative-mood summary ("add", not "added"/"adds"), lowercase, no trailing period, ideally ≤ 72 characters.
29+
- **Body** (optional) explains the *what* and *why*, not the *how*. Separate it from the description with one blank line.
30+
- **Breaking changes** are indicated with a `!` before the colon (e.g. `feat!:`) and/or a `BREAKING CHANGE:` footer describing the change.
31+
- Reference issues/PRs in the footer where relevant (e.g. `Closes #123`).
32+
33+
**Examples:**
34+
35+
```
36+
feat(identity): add bulk user offboarding endpoint
37+
fix(graph): handle expired token on retry
38+
docs: update authentication model overview
39+
refactor(standards)!: rename remediation parameter
40+
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Conventional Commits Check
2+
3+
on:
4+
# Using pull_request_target instead of pull_request for secure handling of fork PRs
5+
pull_request_target:
6+
# Re-run on title edits so a corrected title clears the check
7+
types: [opened, synchronize, reopened, edited]
8+
# Only check PRs targeting the dev branch
9+
branches:
10+
- dev
11+
12+
permissions:
13+
pull-requests: write
14+
issues: write
15+
16+
jobs:
17+
conventional-commits:
18+
name: Validate Conventional Commits
19+
runs-on: ubuntu-slim
20+
steps:
21+
- name: Validate PR title
22+
uses: actions/github-script@v9
23+
with:
24+
github-token: ${{ secrets.GITHUB_TOKEN }}
25+
script: |
26+
const title = context.payload.pull_request.title || '';
27+
const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/i;
28+
29+
if (pattern.test(title)) {
30+
console.log(`✓ PR title follows Conventional Commits format: "${title}"`);
31+
return;
32+
}
33+
34+
console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`);
35+
36+
const message = [
37+
'❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**',
38+
'',
39+
'Expected format: `type(scope)?: description`',
40+
'',
41+
'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`',
42+
'',
43+
'Examples:',
44+
'- `feat: add tenant filter to the user table`',
45+
'- `fix(auth): handle expired refresh tokens`',
46+
'- `docs: update self-hosting guide`',
47+
'',
48+
`Received: \`${title}\``,
49+
'',
50+
'🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.'
51+
].join('\n');
52+
53+
await github.rest.issues.createComment({
54+
...context.repo,
55+
issue_number: context.issue.number,
56+
body: message
57+
});
58+
59+
await github.rest.pulls.update({
60+
...context.repo,
61+
pull_number: context.issue.number,
62+
state: 'closed'
63+
});
64+
65+
core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`);

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@
4444
"@tanstack/query-sync-storage-persister": "^5.90.25",
4545
"@tanstack/react-query": "^5.101.2",
4646
"@tanstack/react-query-devtools": "^5.101.2",
47-
"@tanstack/react-query-persist-client": "^5.96.2",
47+
"@tanstack/react-query-persist-client": "^5.101.2",
4848
"@tanstack/react-table": "^8.19.2",
4949
"@tiptap/core": "^3.22.3",
5050
"@tiptap/extension-heading": "^3.22.3",
5151
"@tiptap/extension-table": "^3.20.5",
52-
"@tiptap/pm": "^3.27.1",
52+
"@tiptap/pm": "^3.27.3",
5353
"@tiptap/react": "^3.20.5",
5454
"@tiptap/starter-kit": "^3.20.5",
5555
"@vvo/tzdb": "^6.198.0",

src/components/CippComponents/CippAppTemplateDrawer.jsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,18 @@ export const CippAppTemplateDrawer = ({
187187
value: typeValue,
188188
}
189189
}
190-
// Normalize "Save as Template" configs (IntuneBody format) to form fields
191-
if (config.IntuneBody && !config.applicationName) {
190+
// Normalize "Save as Template" configs to form fields, canonicalizing on the
191+
// lowercase applicationName so deploy-time ConvertFrom-Json never sees both casings.
192+
if (config.IntuneBody) {
192193
const body = config.IntuneBody
193-
config.applicationName = config.ApplicationName || body.displayName || ''
194-
config.description = body.description || ''
195-
config.AssignTo = config.assignTo || 'On'
194+
if (!config.applicationName) {
195+
config.applicationName = config.ApplicationName || body.displayName || ''
196+
}
197+
delete config.ApplicationName
198+
if (!config.description) config.description = body.description || ''
199+
if (!config.AssignTo) config.AssignTo = config.assignTo || 'On'
196200
// WinGet/Store: packageIdentifier
197-
if (body.packageIdentifier) {
201+
if (!config.packagename && body.packageIdentifier) {
198202
config.packagename = body.packageIdentifier
199203
}
200204
// Chocolatey: extract package name from detection rules or install command
@@ -206,7 +210,7 @@ export const CippAppTemplateDrawer = ({
206210
if (match) config.packagename = match[1]
207211
}
208212
// Chocolatey: custom repo
209-
if (body.installCommandLine) {
213+
if (!config.customRepo && body.installCommandLine) {
210214
const repoMatch = body.installCommandLine.match(/-CustomRepo\s+(\S+)/i)
211215
if (repoMatch) config.customRepo = repoMatch[1]
212216
}

src/components/CippFormPages/CippAddEditUser.jsx

Lines changed: 112 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const CippAddEditUser = (props) => {
2020
const [selectedTemplate, setSelectedTemplate] = useState(null)
2121
const [displayNameManuallySet, setDisplayNameManuallySet] = useState(false)
2222
const [usernameManuallySet, setUsernameManuallySet] = useState(false)
23+
// Tracks the template already applied to the form so we can tell a first
24+
// apply (fill empty fields) apart from a switch (replace/clear stale values)
25+
const appliedTemplateKeyRef = useRef(null)
2326
const router = useRouter()
2427
const { userId } = router.query
2528

@@ -261,6 +264,7 @@ const CippAddEditUser = (props) => {
261264
// Only clear selected template if it's not the default template
262265
if (selectedTemplate && !selectedTemplate.defaultForTenant) {
263266
setSelectedTemplate(null)
267+
appliedTemplateKeyRef.current = null
264268
}
265269
}
266270
}, [
@@ -290,100 +294,123 @@ const CippAddEditUser = (props) => {
290294

291295
// Auto-populate fields when template selected
292296
useEffect(() => {
293-
if (formType === 'add' && watchedFields.userTemplate?.addedFields) {
294-
const template = watchedFields.userTemplate.addedFields
295-
setSelectedTemplate(template)
297+
if (formType !== 'add' || !watchedFields.userTemplate?.addedFields) return
298+
const template = watchedFields.userTemplate.addedFields
299+
const templateKey = watchedFields.userTemplate.value ?? template.GUID ?? template.templateName
296300

297-
// Reset manual edit flags when template changes
298-
setDisplayNameManuallySet(false)
299-
setUsernameManuallySet(false)
301+
// Distinguish the first apply from a switch. On a switch we replace
302+
// template-driven fields (and clear ones the new template doesn't define)
303+
// so stale values from the previous template don't linger. On the first
304+
// apply we only fill fields that have a template value, so we don't clobber
305+
// input the user already entered or copied from another user.
306+
const isSwitch =
307+
appliedTemplateKeyRef.current !== null && appliedTemplateKeyRef.current !== templateKey
308+
appliedTemplateKeyRef.current = templateKey
300309

301-
// Only set fields if they don't already have values (don't override user input)
302-
const setFieldIfEmpty = (fieldName, value) => {
303-
if (value) {
304-
formControl.setValue(fieldName, value)
305-
}
306-
}
310+
setSelectedTemplate(template)
307311

308-
// Populate form fields from template
309-
if (template.primDomain) {
310-
// If primDomain is an object, use it as-is; if it's a string, convert to object
311-
const primDomainValue =
312-
typeof template.primDomain === 'string'
313-
? { label: template.primDomain, value: template.primDomain }
314-
: template.primDomain
315-
formControl.setValue('primDomain', primDomainValue)
316-
}
317-
if (template.usageLocation) {
318-
// Handle both object and string formats
319-
const usageLocationCode =
320-
typeof template.usageLocation === 'string'
321-
? template.usageLocation
322-
: template.usageLocation?.value
323-
const country = countryList.find((c) => c.Code === usageLocationCode)
324-
if (country) {
325-
setFieldIfEmpty('usageLocation', {
326-
label: country.Name,
327-
value: country.Code,
328-
})
329-
}
330-
}
331-
setFieldIfEmpty('jobTitle', template.jobTitle)
332-
setFieldIfEmpty('streetAddress', template.streetAddress)
333-
setFieldIfEmpty('city', template.city)
334-
setFieldIfEmpty('state', template.state)
335-
setFieldIfEmpty('postalCode', template.postalCode)
336-
setFieldIfEmpty('country', template.country)
337-
setFieldIfEmpty('companyName', template.companyName)
338-
setFieldIfEmpty('department', template.department)
339-
setFieldIfEmpty('mobilePhone', template.mobilePhone)
340-
const templateBusinessPhone = Array.isArray(template.businessPhones)
341-
? template.businessPhones[0]
342-
: template.businessPhones
343-
if (templateBusinessPhone) {
344-
formControl.setValue('businessPhones', [templateBusinessPhone])
345-
}
312+
// Reset manual edit flags when template changes
313+
setDisplayNameManuallySet(false)
314+
setUsernameManuallySet(false)
346315

347-
// Handle licenses - need to match the format expected by CippFormLicenseSelector
348-
if (template.licenses && Array.isArray(template.licenses)) {
349-
setFieldIfEmpty('licenses', template.licenses)
316+
// Apply a template value to a field. When the template has a value we set
317+
// it; when it doesn't and this is a switch we clear the field (emptyValue)
318+
// so the previous template's value doesn't linger.
319+
const applyField = (fieldName, value, emptyValue = '') => {
320+
const hasValue = Array.isArray(value)
321+
? value.length > 0
322+
: value !== undefined && value !== null && value !== ''
323+
if (hasValue) {
324+
formControl.setValue(fieldName, value, { shouldDirty: true })
325+
} else if (isSwitch) {
326+
formControl.setValue(fieldName, emptyValue, { shouldDirty: true })
350327
}
328+
}
351329

352-
// Handle groups from template
353-
const templateGroups = template.addToGroups || template.groupMemberships
354-
if (templateGroups) {
355-
const rawGroups = Array.isArray(templateGroups) ? templateGroups : [templateGroups]
356-
const groups = rawGroups.map((g) => {
357-
if (g.label && g.value) return g
358-
const groupType = g.groupTypes?.includes('Unified')
359-
? 'Microsoft 365'
360-
: g.mailEnabled && !g.groupTypes?.includes('Unified')
361-
? g.securityEnabled
362-
? 'Mail-Enabled Security'
363-
: 'Distribution list'
364-
: 'Security'
365-
return {
366-
label: g.displayName,
367-
value: g.id,
368-
addedFields: { groupType },
369-
}
370-
})
371-
if (groups.length > 0) {
372-
const currentGroups = watchedFields.AddToGroups
373-
if (!currentGroups || (Array.isArray(currentGroups) && currentGroups.length === 0)) {
374-
formControl.setValue('AddToGroups', groups, { shouldDirty: true })
375-
}
376-
}
330+
// Primary domain - accept both object and string formats
331+
const primDomainValue = template.primDomain
332+
? typeof template.primDomain === 'string'
333+
? { label: template.primDomain, value: template.primDomain }
334+
: template.primDomain
335+
: null
336+
applyField('primDomain', primDomainValue, null)
337+
338+
// Usage location - accept both object and string formats
339+
const usageLocationCode =
340+
typeof template.usageLocation === 'string'
341+
? template.usageLocation
342+
: template.usageLocation?.value
343+
const country = usageLocationCode
344+
? countryList.find((c) => c.Code === usageLocationCode)
345+
: null
346+
applyField(
347+
'usageLocation',
348+
country ? { label: country.Name, value: country.Code } : null,
349+
null
350+
)
351+
352+
applyField('jobTitle', template.jobTitle)
353+
applyField('streetAddress', template.streetAddress)
354+
applyField('city', template.city)
355+
applyField('state', template.state)
356+
applyField('postalCode', template.postalCode)
357+
applyField('country', template.country)
358+
applyField('companyName', template.companyName)
359+
applyField('department', template.department)
360+
applyField('mobilePhone', template.mobilePhone)
361+
362+
const templateBusinessPhone = Array.isArray(template.businessPhones)
363+
? template.businessPhones[0]
364+
: template.businessPhones
365+
applyField('businessPhones', templateBusinessPhone ? [templateBusinessPhone] : [], [])
366+
367+
// Licenses - match the format expected by CippFormLicenseSelector
368+
applyField(
369+
'licenses',
370+
Array.isArray(template.licenses) ? template.licenses : [],
371+
[]
372+
)
373+
374+
// Groups from template
375+
const templateGroups = template.addToGroups || template.groupMemberships
376+
const rawGroups = templateGroups
377+
? Array.isArray(templateGroups)
378+
? templateGroups
379+
: [templateGroups]
380+
: []
381+
const groups = rawGroups.map((g) => {
382+
if (g.label && g.value) return g
383+
const groupType = g.groupTypes?.includes('Unified')
384+
? 'Microsoft 365'
385+
: g.mailEnabled && !g.groupTypes?.includes('Unified')
386+
? g.securityEnabled
387+
? 'Mail-Enabled Security'
388+
: 'Distribution list'
389+
: 'Security'
390+
return {
391+
label: g.displayName,
392+
value: g.id,
393+
addedFields: { groupType },
377394
}
395+
})
396+
applyField('AddToGroups', groups, [])
378397

379-
// Populate custom user attributes from template
380-
if (template.defaultAttributes) {
381-
Object.entries(template.defaultAttributes).forEach(([key, attr]) => {
382-
if (attr?.Value) {
383-
setFieldIfEmpty(`defaultAttributes.${key}.Value`, attr.Value)
384-
}
398+
// Custom user attributes. On a switch, clear every known attribute field
399+
// first so attributes the new template doesn't define don't linger, then
400+
// apply the template's values.
401+
if (isSwitch) {
402+
userSettingsDefaults?.userAttributes
403+
?.filter((attribute) => attribute.value !== 'sponsor')
404+
.forEach((attribute) => {
405+
formControl.setValue(`defaultAttributes.${attribute.label}.Value`, '', {
406+
shouldDirty: true,
407+
})
385408
})
386-
}
409+
}
410+
if (template.defaultAttributes) {
411+
Object.entries(template.defaultAttributes).forEach(([key, attr]) => {
412+
applyField(`defaultAttributes.${key}.Value`, attr?.Value)
413+
})
387414
}
388415
}, [watchedFields.userTemplate, formType])
389416

0 commit comments

Comments
 (0)