diff --git a/backend/src/Designer/Controllers/ContactController.cs b/backend/src/Designer/Controllers/ContactController.cs new file mode 100644 index 00000000000..c32609a1dd8 --- /dev/null +++ b/backend/src/Designer/Controllers/ContactController.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Helpers; +using Altinn.Studio.Designer.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Altinn.Studio.Designer.Controllers +{ + [Route("designer/api/[controller]")] + [ApiController] + public class ContactController : ControllerBase + { + private readonly IGitea _giteaService; + + public ContactController(IGitea giteaService) + { + _giteaService = giteaService; + } + + [AllowAnonymous] + [HttpGet("belongs-to-org")] + public async Task BelongsToOrg() + { + bool isNotAuthenticated = !AuthenticationHelper.IsAuthenticated(HttpContext); + if (isNotAuthenticated) + { + return Ok(new BelongsToOrgDto { BelongsToOrg = false }); + } + + try + { + var organizations = await _giteaService.GetUserOrganizations(); + return Ok(new BelongsToOrgDto { BelongsToOrg = organizations.Count > 0 }); + } + catch (Exception) + { + return Ok(new BelongsToOrgDto { BelongsToOrg = false }); + } + } + } +} diff --git a/backend/src/Designer/Helpers/AuthenticationHelper.cs b/backend/src/Designer/Helpers/AuthenticationHelper.cs index 2675d25ebde..503ee828878 100644 --- a/backend/src/Designer/Helpers/AuthenticationHelper.cs +++ b/backend/src/Designer/Helpers/AuthenticationHelper.cs @@ -23,5 +23,10 @@ public static Task GetDeveloperAppTokenAsync(this HttpContext context) { return context.GetTokenAsync("access_token"); } + + public static bool IsAuthenticated(HttpContext context) + { + return context.User.Identity?.IsAuthenticated ?? false; + } } } diff --git a/backend/src/Designer/Models/Dto/BelongsToOrg.cs b/backend/src/Designer/Models/Dto/BelongsToOrg.cs new file mode 100644 index 00000000000..c79b96767d0 --- /dev/null +++ b/backend/src/Designer/Models/Dto/BelongsToOrg.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +public class BelongsToOrgDto +{ + [JsonPropertyName("belongsToOrg")] + public bool BelongsToOrg { get; set; } +} diff --git a/backend/tests/Designer.Tests/Controllers/ContactController/FetchBelongsToOrgTests.cs b/backend/tests/Designer.Tests/Controllers/ContactController/FetchBelongsToOrgTests.cs new file mode 100644 index 00000000000..26813538161 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/ContactController/FetchBelongsToOrgTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Configuration; +using Altinn.Studio.Designer.Services.Interfaces; +using Designer.Tests.Controllers.ApiTests; +using Designer.Tests.Mocks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Designer.Tests.Controllers.ContactController; + +public class FetchBelongsToOrgTests : DesignerEndpointsTestsBase, + IClassFixture> +{ + public FetchBelongsToOrgTests(WebApplicationFactory factory) : base(factory) + { + } + + protected override void ConfigureTestServices(IServiceCollection services) + { + services.Configure(c => + c.RepositoryLocation = TestRepositoriesLocation); + services.AddSingleton(); + } + + + [Fact] + public async Task UsersThatBelongsToOrg_ShouldReturn_True() + { + string url = "/designer/api/contact/belongs-to-org"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, url); + + var response = await HttpClient.SendAsync(httpRequestMessage); + var responseContent = await response.Content.ReadAsAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(responseContent.BelongsToOrg); + } +} diff --git a/backend/tests/Designer.Tests/Mocks/IGiteaMock.cs b/backend/tests/Designer.Tests/Mocks/IGiteaMock.cs index 37094770ba9..f717e8efbda 100644 --- a/backend/tests/Designer.Tests/Mocks/IGiteaMock.cs +++ b/backend/tests/Designer.Tests/Mocks/IGiteaMock.cs @@ -9,6 +9,7 @@ using Altinn.Studio.Designer.Services.Interfaces; using Designer.Tests.Utils; +using Organization = Altinn.Studio.Designer.RepositoryClient.Model.Organization; namespace Designer.Tests.Mocks { @@ -131,7 +132,13 @@ public Task> GetTeams() public Task> GetUserOrganizations() { - throw new NotImplementedException(); + var organizations = new List + { + new Organization { Username = "Org1", Id = 1 }, // Example items + new Organization { Username = "Org2", Id = 2 } + }; + + return Task.FromResult(organizations); } public Task> GetUserRepos() diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index d68daaab64d..f362b7cd0f5 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -146,11 +146,16 @@ "code_list_editor.text_resource.label.select": "Finn ledetekst for verdi nummer {{number}}", "code_list_editor.text_resource.label.value": "Oppgi ledetekst for verdi nummer {{number}}", "code_list_editor.value_item": "Verdi for alternativ {{number}}", + "contact.altinn_servicedesk.content": "Er du tjenesteeier og har du behov for hjelp? Ta kontakt med oss!", + "contact.altinn_servicedesk.heading": "Altinn Servicedesk", "contact.email.content": "Du kan skrive en e-post til Altinn servicedesk hvis du har spørsmål om å opprette organisasjoner eller miljøer, opplever tekniske problemer eller har spørsmål om dokumentasjonen eller andre ting.", "contact.email.heading": "Send e-post", "contact.github_issue.content": "Hvis du har behov for funksjonalitet eller ser feil og mangler i Studio som vi må fikse, kan du opprette en sak i Github, så ser vi på den.", "contact.github_issue.heading": "Rapporter feil og mangler til oss", "contact.github_issue.link_label": "Opprett sak i Github", + "contact.serviceDesk.email": "E-post: tjenesteeier@altinn.no", + "contact.serviceDesk.emergencyPhone": "Vakttelefon: 94 49 00 02 (tilgjengelig kl. 15:45–07:00)", + "contact.serviceDesk.phone": "Telefon: 75 00 62 99", "contact.slack.content": "Hvis du har spørsmål om hvordan du bygger en app, kan du snakke direkte med utviklingsteamet i Altinn Studio på Slack. De hjelper deg med å", "contact.slack.content_list": "<0>bygge appene slik du ønsker<0>svare på spørsmål og veilede deg<0>ta imot innspill på ny funksjonalitet", "contact.slack.heading": "Skriv melding til oss Slack", diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 410030811b2..945c53555a6 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -184,3 +184,6 @@ export const processEditorDataTypePath = (org, app, dataTypeId, taskId) => `${ba // Event Hubs export const SyncEventsWebSocketHub = () => '/sync-hub'; + +// Contact +export const belongsToOrg = () => `${basePath}/contact/belongs-to-org`; diff --git a/frontend/packages/shared/src/api/queries.ts b/frontend/packages/shared/src/api/queries.ts index fa66f176057..04d10a7bceb 100644 --- a/frontend/packages/shared/src/api/queries.ts +++ b/frontend/packages/shared/src/api/queries.ts @@ -4,6 +4,7 @@ import { appMetadataPath, appPolicyPath, appVersionPath, + belongsToOrg, branchStatusPath, dataModelMetadataPath, dataModelPath, @@ -165,3 +166,6 @@ export const getAltinn2DelegationsCount = (org: string, serviceCode: string, ser // ProcessEditor export const getBpmnFile = (org: string, app: string) => get(processEditorPath(org, app)); export const getProcessTaskType = (org: string, app: string, taskId: string) => get(`${processTaskTypePath(org, app, taskId)}`); + +// Contact Page +export const fetchBelongsToGiteaOrg = () => get(belongsToOrg()); diff --git a/frontend/packages/shared/src/getInTouch/providers/PhoneContactProvider.ts b/frontend/packages/shared/src/getInTouch/providers/PhoneContactProvider.ts new file mode 100644 index 00000000000..d1c440c711e --- /dev/null +++ b/frontend/packages/shared/src/getInTouch/providers/PhoneContactProvider.ts @@ -0,0 +1,14 @@ +import { type GetInTouchProvider } from '../interfaces/GetInTouchProvider'; + +type PhoneChannel = 'phone' | 'emergencyPhone'; + +const phoneChannelMap: Record = { + phone: 'tel:75006299', + emergencyPhone: 'tel:94490002', +}; + +export class PhoneContactProvider implements GetInTouchProvider { + public buildContactUrl(selectedChannel: PhoneChannel): string { + return phoneChannelMap[selectedChannel]; + } +} diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts index 616564b7aa6..0b747d4b18d 100644 --- a/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/frontend/packages/shared/src/mocks/queriesMock.ts @@ -193,6 +193,9 @@ export const queriesMock: ServicesContextProps = { .mockImplementation(() => Promise.resolve([])), updateSelectedMaskinportenScopes: jest.fn().mockImplementation(() => Promise.resolve()), + // Queries - Contact + fetchBelongsToGiteaOrg: jest.fn().mockImplementation(() => Promise.resolve([])), + // Mutations addAppAttachmentMetadata: jest.fn().mockImplementation(() => Promise.resolve()), addDataTypeToAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()), diff --git a/frontend/packages/shared/src/types/QueryKey.ts b/frontend/packages/shared/src/types/QueryKey.ts index 3f2cabcac3e..54f741657ff 100644 --- a/frontend/packages/shared/src/types/QueryKey.ts +++ b/frontend/packages/shared/src/types/QueryKey.ts @@ -5,6 +5,7 @@ export enum QueryKey { AppPolicy = 'AppPolicy', AppReleases = 'AppReleases', AppVersion = 'AppVersion', + BelongsToOrg = 'BelongsToOrg', BranchStatus = 'BranchStatus', CurrentUser = 'CurrentUser', DataModelMetadata = 'DataModelMetadata', @@ -14,7 +15,6 @@ export enum QueryKey { DeployPermissions = 'DeployPermissions', Environments = 'Environments', FetchBpmn = 'FetchBpmn', - FetchTextResources = 'FetchTextResources', FormComponent = 'FormComponent', FormLayoutSettings = 'FormLayoutSettings', FormLayouts = 'FormLayouts', @@ -24,7 +24,6 @@ export enum QueryKey { InstanceId = 'InstanceId', JsonSchema = 'JsonSchema', LayoutNames = 'LayoutNames', - LayoutSchema = 'LayoutSchema', LayoutSets = 'LayoutSets', LayoutSetsExtended = 'LayoutSetsExtended', OptionList = 'OptionList', @@ -36,7 +35,6 @@ export enum QueryKey { ProcessTaskDataType = 'ProcessTaskDataType', RepoMetadata = 'RepoMetadata', RepoPullData = 'RepoPullData', - RepoReset = 'RepoReset', RepoStatus = 'RepoStatus', RepoDiff = 'RepoDiff', RuleConfig = 'RuleConfig', @@ -59,9 +57,6 @@ export enum QueryKey { ResourcePolicyAccessPackages = 'ResourcePolicyAccessPackages', ResourcePolicyAccessPackageServices = 'ResourcePolicyAccessPackageServices', ResourcePublishStatus = 'ResourcePublishStatus', - ResourceSectors = 'ResourceSectors', - ResourceThematicEurovoc = 'ResourceThematicEurovoc', - ResourceThematicLos = 'ResourceThematicLos', SingleResource = 'SingleResource', ValidatePolicy = 'ValidatePolicy', ValidateResource = 'ValidateResource', diff --git a/frontend/studio-root/components/ContactSection/ContactSection.tsx b/frontend/studio-root/components/ContactSection/ContactSection.tsx index 64b1d882402..3d63de43ae5 100644 --- a/frontend/studio-root/components/ContactSection/ContactSection.tsx +++ b/frontend/studio-root/components/ContactSection/ContactSection.tsx @@ -6,7 +6,7 @@ import classes from './ContactSection.module.css'; export type ContactSectionProps = { title: string; description: string; - link: { + link?: { name: string; href: string; }; @@ -31,7 +31,7 @@ export const ContactSection = ({ {description} {additionalContent && {additionalContent}} - {link.name} + {link && {link.name}} ); diff --git a/frontend/studio-root/components/ContactServiceDesk/ContactServiceDesk.tsx b/frontend/studio-root/components/ContactServiceDesk/ContactServiceDesk.tsx new file mode 100644 index 00000000000..1a20ec1c2dd --- /dev/null +++ b/frontend/studio-root/components/ContactServiceDesk/ContactServiceDesk.tsx @@ -0,0 +1,48 @@ +import React, { type ReactElement } from 'react'; +import { GetInTouchWith } from 'app-shared/getInTouch'; +import { EmailContactProvider } from 'app-shared/getInTouch/providers'; +import { StudioList, StudioLink } from '@studio/components'; +import { Trans } from 'react-i18next'; +import { PhoneContactProvider } from 'app-shared/getInTouch/providers/PhoneContactProvider'; + +export const ContactServiceDesk = (): ReactElement => { + const contactByEmail = new GetInTouchWith(new EmailContactProvider()); + const contactByPhone = new GetInTouchWith(new PhoneContactProvider()); + return ( + + + + , + a: {null}, + }} + /> + + + + , + a: {null}, + }} + /> + + + + , + a: {null}, + }} + /> + + + + ); +}; diff --git a/frontend/studio-root/components/ContactServiceDesk/index.ts b/frontend/studio-root/components/ContactServiceDesk/index.ts new file mode 100644 index 00000000000..2cbb79dacd0 --- /dev/null +++ b/frontend/studio-root/components/ContactServiceDesk/index.ts @@ -0,0 +1 @@ +export { ContactServiceDesk } from './ContactServiceDesk'; diff --git a/frontend/studio-root/pages/Contact/ContactPage.test.tsx b/frontend/studio-root/pages/Contact/ContactPage.test.tsx index 7b4be059c57..df8a4be8cc0 100644 --- a/frontend/studio-root/pages/Contact/ContactPage.test.tsx +++ b/frontend/studio-root/pages/Contact/ContactPage.test.tsx @@ -2,6 +2,13 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { ContactPage } from './ContactPage'; +import { useFetchBelongsToOrgQuery } from '../hooks/queries/useFetchBelongsToOrgQuery'; + +jest.mock('../hooks/queries/useFetchBelongsToOrgQuery'); + +(useFetchBelongsToOrgQuery as jest.Mock).mockReturnValue({ + data: { belongsToOrg: false }, +}); describe('ContactPage', () => { it('should display the main heading', () => { @@ -44,4 +51,23 @@ describe('ContactPage', () => { screen.getByRole('link', { name: textMock('contact.github_issue.link_label') }), ).toBeInTheDocument(); }); + + it('should not render contact info for "Altinn Servicedesk" if the user does not belong to a org', () => { + (useFetchBelongsToOrgQuery as jest.Mock).mockReturnValue({ + data: { belongsToOrg: false }, + }); + render(); + + expect(screen.queryByRole('heading', { name: textMock('contact.altinn_servicedesk.heading') })); + expect(screen.queryByText(textMock('contact.altinn_servicedesk.content'))); + }); + + it('should display contact information to "Altinn Servicedesk" if user belongs to an org', () => { + (useFetchBelongsToOrgQuery as jest.Mock).mockReturnValue({ + data: { belongsToOrg: true }, + }); + render(); + expect(screen.getByRole('heading', { name: textMock('contact.altinn_servicedesk.heading') })); + expect(screen.getByText(textMock('contact.altinn_servicedesk.content'))); + }); }); diff --git a/frontend/studio-root/pages/Contact/ContactPage.tsx b/frontend/studio-root/pages/Contact/ContactPage.tsx index 7aa6d6e8f8d..406f4fd69f3 100644 --- a/frontend/studio-root/pages/Contact/ContactPage.tsx +++ b/frontend/studio-root/pages/Contact/ContactPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import classes from './ContactPage.module.css'; import { Trans, useTranslation } from 'react-i18next'; -import { EnvelopeClosedIcon, SlackIcon, GitHubIcon } from '@studio/icons'; +import { EnvelopeClosedIcon, SlackIcon, GitHubIcon, PersonHeadsetIcon } from '@studio/icons'; import { GetInTouchWith } from 'app-shared/getInTouch'; import { EmailContactProvider, @@ -14,6 +14,12 @@ import { StudioParagraph, } from '@studio/components'; import { ContactSection, type ContactSectionProps } from '../../components/ContactSection'; +import { ContactServiceDesk } from '../../components/ContactServiceDesk'; +import { useFetchBelongsToOrgQuery } from '../hooks/queries/useFetchBelongsToOrgQuery'; + +type ContactSectionMetadata = { + shouldHideSection?: boolean; +}; export const ContactPage = (): React.ReactElement => { const { t } = useTranslation(); @@ -21,7 +27,9 @@ export const ContactPage = (): React.ReactElement => { const contactBySlack = new GetInTouchWith(new SlackContactProvider()); const contactByGitHubIssue = new GetInTouchWith(new GitHubIssueContactProvider()); - const contactSections: Array = [ + const { data: belongsToOrgData } = useFetchBelongsToOrgQuery(); + + const contactSections: Array = [ { title: t('contact.email.heading'), description: t('contact.email.content'), @@ -58,6 +66,13 @@ export const ContactPage = (): React.ReactElement => { }, Icon: GitHubIcon, }, + { + title: t('contact.altinn_servicedesk.heading'), + additionalContent: , + description: t('contact.altinn_servicedesk.content'), + Icon: PersonHeadsetIcon, + shouldHideSection: !belongsToOrgData?.belongsToOrg, + }, ]; return ( @@ -69,7 +84,7 @@ export const ContactPage = (): React.ReactElement => { {t('general.contact')} - {contactSections.map((contactSection) => ( + {contactSections.filter(filterHiddenSections).map((contactSection) => ( ))} @@ -77,3 +92,7 @@ export const ContactPage = (): React.ReactElement => { ); }; + +function filterHiddenSections(section: ContactSectionProps & ContactSectionMetadata): boolean { + return !section.shouldHideSection; +} diff --git a/frontend/studio-root/pages/hooks/queries/useFetchBelongsToOrgQuery.ts b/frontend/studio-root/pages/hooks/queries/useFetchBelongsToOrgQuery.ts new file mode 100644 index 00000000000..419678942e0 --- /dev/null +++ b/frontend/studio-root/pages/hooks/queries/useFetchBelongsToOrgQuery.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +type BelongsToOrg = { + belongsToOrg: boolean; +}; + +export const useFetchBelongsToOrgQuery = (): UseQueryResult => { + const { fetchBelongsToGiteaOrg } = useServicesContext(); + + return useQuery({ + queryKey: [QueryKey.BelongsToOrg], + queryFn: () => fetchBelongsToGiteaOrg(), + }); +};