Skip to content

Commit

Permalink
feat(contact): display Altinn Servicedesk contact if user belongs to …
Browse files Browse the repository at this point in the history
…org (#14371)

Co-authored-by: Nina Kylstad <nkylstad@gmail.com>
  • Loading branch information
framitdavid and nkylstad authored Jan 16, 2025
1 parent 60ea6c6 commit e33453d
Show file tree
Hide file tree
Showing 17 changed files with 281 additions and 12 deletions.
42 changes: 42 additions & 0 deletions backend/src/Designer/Controllers/ContactController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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 });
}
}
}
}
5 changes: 5 additions & 0 deletions backend/src/Designer/Helpers/AuthenticationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@ public static Task<string> GetDeveloperAppTokenAsync(this HttpContext context)
{
return context.GetTokenAsync("access_token");
}

public static bool IsAuthenticated(HttpContext context)
{
return context.User.Identity?.IsAuthenticated ?? false;
}
}
}
7 changes: 7 additions & 0 deletions backend/src/Designer/Models/Dto/BelongsToOrg.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;

public class BelongsToOrgDto
{
[JsonPropertyName("belongsToOrg")]
public bool BelongsToOrg { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Designer.Tests.Controllers.ApiTests;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Designer.Tests.Controllers.ContactController;

public class FetchBelongsToOrgTests : DesignerEndpointsTestsBase<FetchBelongsToOrgTests>,
IClassFixture<WebApplicationFactory<Program>>
{
public FetchBelongsToOrgTests(WebApplicationFactory<Program> factory) : base(factory)
{
}

[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<BelongsToOrgDto>();

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(responseContent.BelongsToOrg);
}

[Fact]
public async Task UsersThatDoNotBelongsToOrg_ShouldReturn_False_IfAnonymousUser()
{
string configPath = GetConfigPath();
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile(configPath, false, false)
.AddJsonStream(GenerateJsonOverrideConfig())
.AddEnvironmentVariables()
.Build();

var anonymousClient = Factory.WithWebHostBuilder(builder =>
{
builder.UseConfiguration(configuration);
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Anonymous")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Anonymous", options => { });
});
}).CreateDefaultClient();

string url = "/designer/api/contact/belongs-to-org";

using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, url);

var response = await anonymousClient.SendAsync(httpRequestMessage);
var responseContent = await response.Content.ReadAsAsync<BelongsToOrgDto>();

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(responseContent.BelongsToOrg);
}
}
9 changes: 8 additions & 1 deletion backend/tests/Designer.Tests/Mocks/IGiteaMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -131,7 +132,13 @@ public Task<List<Team>> GetTeams()

public Task<List<Organization>> GetUserOrganizations()
{
throw new NotImplementedException();
var organizations = new List<Organization>
{
new Organization { Username = "Org1", Id = 1 }, // Example items
new Organization { Username = "Org2", Id = 2 }
};

return Task.FromResult(organizations);
}

public Task<IList<Repository>> GetUserRepos()
Expand Down
5 changes: 5 additions & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,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": "<b>E-post:</b> <a>tjenesteeier@altinn.no</a>",
"contact.serviceDesk.emergencyPhone": "<b>Vakttelefon:</b> <a>94 49 00 02</a> (tilgjengelig kl. 15:45–07:00)",
"contact.serviceDesk.phone": "<b>Telefon:</b> <a>75 00 62 99</a>",
"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><0>svare på spørsmål og veilede deg</0><0>ta imot innspill på ny funksjonalitet</0>",
"contact.slack.heading": "Skriv melding til oss Slack",
Expand Down
3 changes: 3 additions & 0 deletions frontend/packages/shared/src/api/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,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`;
4 changes: 4 additions & 0 deletions frontend/packages/shared/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
appMetadataPath,
appPolicyPath,
appVersionPath,
belongsToOrg,
branchStatusPath,
dataModelMetadataPath,
dataModelPath,
Expand Down Expand Up @@ -168,3 +169,6 @@ export const getAltinn2DelegationsCount = (org: string, serviceCode: string, ser
// ProcessEditor
export const getBpmnFile = (org: string, app: string) => get<string>(processEditorPath(org, app));
export const getProcessTaskType = (org: string, app: string, taskId: string) => get<string>(`${processTaskTypePath(org, app, taskId)}`);

// Contact Page
export const fetchBelongsToGiteaOrg = () => get(belongsToOrg());
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type GetInTouchProvider } from '../interfaces/GetInTouchProvider';

type PhoneChannel = 'phone' | 'emergencyPhone';

const phoneChannelMap: Record<PhoneChannel, string> = {
phone: 'tel:75006299',
emergencyPhone: 'tel:94490002',
};

export class PhoneContactProvider implements GetInTouchProvider<PhoneChannel> {
public buildContactUrl(selectedChannel: PhoneChannel): string {
return phoneChannelMap[selectedChannel];
}
}
5 changes: 5 additions & 0 deletions frontend/packages/shared/src/mocks/queriesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ export const queriesMock: ServicesContextProps = {
.mockImplementation(() => Promise.resolve<MaskinportenScope[]>([])),
updateSelectedMaskinportenScopes: jest.fn().mockImplementation(() => Promise.resolve()),

// Queries - Contact
fetchBelongsToGiteaOrg: jest
.fn()
.mockImplementation(() => Promise.resolve({ belongsToOrg: true })),

// Mutations
addAppAttachmentMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
addDataTypeToAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
Expand Down
7 changes: 1 addition & 6 deletions frontend/packages/shared/src/types/QueryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum QueryKey {
AppPolicy = 'AppPolicy',
AppReleases = 'AppReleases',
AppVersion = 'AppVersion',
BelongsToOrg = 'BelongsToOrg',
BranchStatus = 'BranchStatus',
CurrentUser = 'CurrentUser',
DataModelMetadata = 'DataModelMetadata',
Expand All @@ -14,7 +15,6 @@ export enum QueryKey {
DeployPermissions = 'DeployPermissions',
Environments = 'Environments',
FetchBpmn = 'FetchBpmn',
FetchTextResources = 'FetchTextResources',
FormComponent = 'FormComponent',
FormLayoutSettings = 'FormLayoutSettings',
FormLayouts = 'FormLayouts',
Expand All @@ -24,7 +24,6 @@ export enum QueryKey {
InstanceId = 'InstanceId',
JsonSchema = 'JsonSchema',
LayoutNames = 'LayoutNames',
LayoutSchema = 'LayoutSchema',
LayoutSets = 'LayoutSets',
LayoutSetsExtended = 'LayoutSetsExtended',
OptionList = 'OptionList',
Expand All @@ -36,7 +35,6 @@ export enum QueryKey {
ProcessTaskDataType = 'ProcessTaskDataType',
RepoMetadata = 'RepoMetadata',
RepoPullData = 'RepoPullData',
RepoReset = 'RepoReset',
RepoStatus = 'RepoStatus',
RepoDiff = 'RepoDiff',
RuleConfig = 'RuleConfig',
Expand All @@ -60,9 +58,6 @@ export enum QueryKey {
ResourcePolicyAccessPackages = 'ResourcePolicyAccessPackages',
ResourcePolicyAccessPackageServices = 'ResourcePolicyAccessPackageServices',
ResourcePublishStatus = 'ResourcePublishStatus',
ResourceSectors = 'ResourceSectors',
ResourceThematicEurovoc = 'ResourceThematicEurovoc',
ResourceThematicLos = 'ResourceThematicLos',
SingleResource = 'SingleResource',
ValidatePolicy = 'ValidatePolicy',
ValidateResource = 'ValidateResource',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import classes from './ContactSection.module.css';
export type ContactSectionProps = {
title: string;
description: string;
link: {
link?: {
name: string;
href: string;
};
Expand All @@ -31,7 +31,7 @@ export const ContactSection = ({
</StudioHeading>
<StudioParagraph spacing>{description}</StudioParagraph>
{additionalContent && <span>{additionalContent}</span>}
<StudioLink href={link.href}>{link.name}</StudioLink>
{link && <StudioLink href={link.href}>{link.name}</StudioLink>}
</div>
</section>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<StudioList.Root>
<StudioList.Unordered>
<StudioList.Item>
<Trans
i18nKey='contact.serviceDesk.phone'
components={{
b: <b />,
a: <StudioLink href={contactByPhone.url('phone')}>{null}</StudioLink>,
}}
/>
</StudioList.Item>

<StudioList.Item>
<Trans
i18nKey='contact.serviceDesk.emergencyPhone'
values={{ phoneNumber: contactByPhone.url('phone') }}
components={{
b: <b />,
a: <StudioLink href={contactByPhone.url('emergencyPhone')}>{null}</StudioLink>,
}}
/>
</StudioList.Item>

<StudioList.Item>
<Trans
i18nKey='contact.serviceDesk.email'
values={{ phoneNumber: contactByPhone.url('phone') }}
components={{
b: <b />,
a: <StudioLink href={contactByEmail.url('serviceOwner')}>{null}</StudioLink>,
}}
/>
</StudioList.Item>
</StudioList.Unordered>
</StudioList.Root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ContactServiceDesk } from './ContactServiceDesk';
32 changes: 32 additions & 0 deletions frontend/studio-root/pages/Contact/ContactPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -44,4 +51,29 @@ 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(<ContactPage />);

expect(
screen.queryByRole('heading', { name: textMock('contact.altinn_servicedesk.heading') }),
).not.toBeInTheDocument();
expect(
screen.queryByText(textMock('contact.altinn_servicedesk.content')),
).not.toBeInTheDocument();
});

it('should display contact information to "Altinn Servicedesk" if user belongs to an org', () => {
(useFetchBelongsToOrgQuery as jest.Mock).mockReturnValue({
data: { belongsToOrg: true },
});
render(<ContactPage />);
expect(
screen.getByRole('heading', { name: textMock('contact.altinn_servicedesk.heading') }),
).toBeInTheDocument();
expect(screen.getByText(textMock('contact.altinn_servicedesk.content'))).toBeInTheDocument();
});
});
Loading

0 comments on commit e33453d

Please sign in to comment.