Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show header when not in iframe #241

Merged
merged 5 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion mocks/edusharing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,15 @@ export class EdusharingServer {
'https://purl.imsglobal.org/spec/lti/claim/context': {
id: this.contextId,
label: this.custom.user,
title: 'Example course name',
},
'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': urlJoin(
editorUrl,
'lti/launch'
),
'https://purl.imsglobal.org/spec/lti/claim/resource_link': {
id: this.custom.nodeId,
title: 'Test Content',
title: 'Example content name',
},
'https://purl.imsglobal.org/spec/lti/claim/launch_presentation': {
document_target: 'window',
Expand Down
4 changes: 2 additions & 2 deletions mocks/itslearning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class ItslearningServer {
'https://purl.imsglobal.org/spec/lti/claim/roles': this.roles,
'https://purl.imsglobal.org/spec/lti/claim/context': {
id: itslearningMockContextId,
title: 'Serlo',
title: 'Example course name',
type: ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection'],
},
'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': urlJoin(
Expand All @@ -75,7 +75,7 @@ export class ItslearningServer {
),
'https://purl.imsglobal.org/spec/lti/claim/resource_link': {
id: '3061:3245',
title: 'Test Content',
title: 'Example content name',
},
'https://purl.imsglobal.org/spec/lti/claim/message_type':
'LtiResourceLinkRequest',
Expand Down
4 changes: 2 additions & 2 deletions src/backend/edusharing/route-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { IdToken } from 'ltijs'
import jwt from 'jsonwebtoken'
import * as t from 'io-ts'
import urlJoin from 'url-join'
Expand All @@ -13,6 +12,7 @@ import { Collection, MongoClient, ObjectId } from 'mongodb'
import { Request, Response } from 'express'
import { getEdusharingAsToolConfiguration } from './edusharing-as-tool-configuration'
import config from '../../utils/config'
import { IdToken } from '../types/idtoken'

const editorUrl = config.EDITOR_URL

Expand Down Expand Up @@ -49,7 +49,7 @@ export async function init() {
}

export async function start(_: Request, res: Response) {
const idToken = res.locals.token as IdToken
const idToken = res.locals.token as unknown as IdToken
const issWhenEdusharingLaunchedSerloEditor = idToken.iss

const custom: unknown = res.locals.context?.custom
Expand Down
3 changes: 3 additions & 0 deletions src/backend/error-message-to-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function errorMessageToUser(errorDetails: string) {
return `Es ist leider ein Fehler aufgetreten. Bitte wende dich an [email protected] mit dieser Fehlermeldung: ${errorDetails}`
}
104 changes: 72 additions & 32 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IdToken, Provider as ltijs } from 'ltijs'
import { Provider as ltijs } from 'ltijs'
import path from 'path'

import * as t from 'io-ts'
Expand All @@ -12,6 +12,8 @@ import * as ai from './ai-route-handlers'
import { getMariaDB } from './mariadb'
import * as media from './media-route-handlers'
import { logger } from '../utils/logger'
import { IdToken } from './types/idtoken'
import { errorMessageToUser } from './error-message-to-user'

const ltijsKey = config.LTIJS_KEY

Expand Down Expand Up @@ -130,9 +132,9 @@ const setup = async () => {
// Successful LTI resource link launch
ltijs.onConnect((idToken, req, res) => {
if (idToken.iss.includes('edu-sharing')) {
void onConnectEdusharing(idToken, req, res)
void onConnectEdusharing(idToken as unknown as IdToken, req, res)
} else {
void onConnectDefault(idToken, req, res)
void onConnectDefault(idToken as unknown as IdToken, req, res)
}
}, {})

Expand All @@ -141,11 +143,7 @@ const setup = async () => {
_: Request,
res: Response
) {
// @ts-expect-error @types/ltijs
const resourceLinkId: string = idToken.platformContext.resource.id
// @ts-expect-error @types/ltijs
const custom: unknown = idToken.platformContext.custom

const custom: unknown = idToken.platformContext?.custom
const expectedCustomType = t.intersection([
t.type({
getContentApiUrl: t.string,
Expand All @@ -161,16 +159,23 @@ const setup = async () => {
version: t.string,
}),
])

if (!expectedCustomType.is(custom)) {
res
.status(400)
.send(
`Unexpected type of LTI 'custom' claim. Got ${JSON.stringify(custom)}`
errorMessageToUser(
`Unexpected type of LTI 'custom' claim. Got ${JSON.stringify(custom)}`
)
)
return
}

const resourceLinkId = idToken.platformContext?.resource?.id
if (!resourceLinkId) {
res.status(400).send(errorMessageToUser('resource link id missing'))
return
}

const edusharingNodeId = custom.nodeId

const entityId = await getEntityId()
Expand All @@ -196,28 +201,33 @@ const setup = async () => {
typeof custom.postContentApiUrl === 'string' ? 'write' : 'read'
const accessToken = createAccessToken(editorMode, entityId, ltijsKey)

const searchParams = new URLSearchParams()
searchParams.append('accessToken', accessToken)
searchParams.append('resourceLinkId', resourceLinkId)
searchParams.append('ltik', res.locals.ltik)
searchParams.append('testingSecret', config.SERLO_EDITOR_TESTING_SECRET)
const ltik = res.locals.ltik
const title = idToken.platformContext?.resource?.title
const contextTitle = idToken.platformContext?.context?.title

return ltijs.redirect(res, `/app?${searchParams}`)
const searchParams = createSearchParams({
ltik,
accessToken,
resourceLinkId,
title,
contextTitle,
})

return ltijs.redirect(res, `/app?${searchParams.toString()}`)
}

async function onConnectDefault(
idToken: IdToken,
req: Request,
res: Response
) {
// Get customId from lti custom claim or alternatively search query parameters
// Using search query params is suggested by ltijs, see: https://github.com/Cvmcosta/ltijs/issues/100#issuecomment-832284300
// @ts-expect-error @types/ltijs
const customId = idToken.platformContext.custom.id ?? req.query.id
if (!customId) return res.send('Missing customId!')
async function onConnectDefault(idToken: IdToken, _: Request, res: Response) {
const customId = idToken.platformContext?.custom?.id
if (!customId) {
res.status(400).send(errorMessageToUser('custom id missing'))
return
}

// @ts-expect-error @types/ltijs
const resourceLinkId: string = idToken.platformContext.resource.id
const resourceLinkId = idToken.platformContext?.resource?.id
if (!resourceLinkId) {
res.status(400).send(errorMessageToUser('resource link id missing'))
return
}

logger.info('ltijs.onConnect -> idToken: ', idToken)

Expand All @@ -240,7 +250,7 @@ const setup = async () => {
)

if (!entity) {
res.send('<div>Dieser Inhalt wurde nicht gefunden.</div>')
res.send(errorMessageToUser('Content not found in database'))
return
}

Expand All @@ -260,8 +270,7 @@ const setup = async () => {
// This role is sent in the itslearning library and we disallow editing there for now
// 'membership#Member',
]
// @ts-expect-error @types/ltijs
const courseMembershipRole = idToken.platformContext.roles?.find((role) =>
const courseMembershipRole = idToken.platformContext?.roles?.find((role) =>
role.includes('membership#')
)
const editorMode =
Expand All @@ -282,12 +291,43 @@ const setup = async () => {
)
}

const ltik = res.locals.ltik
const title = idToken.platformContext?.resource?.title
const contextTitle = idToken.platformContext?.context?.title

const searchParams = createSearchParams({
ltik,
accessToken,
resourceLinkId,
contextTitle,
title,
})

return ltijs.redirect(res, `/app?${searchParams.toString()}`)
}

function createSearchParams({
ltik,
accessToken,
resourceLinkId,
contextTitle,
title,
}: {
ltik: string
accessToken: string
resourceLinkId: string
contextTitle?: string
title?: string
}) {
const searchParams = new URLSearchParams()
searchParams.append('accessToken', accessToken)
searchParams.append('resourceLinkId', resourceLinkId)
searchParams.append('testingSecret', config.SERLO_EDITOR_TESTING_SECRET)
searchParams.append('ltik', ltik)
searchParams.append('contextTitle', contextTitle ?? '')
searchParams.append('title', title ?? '')

return ltijs.redirect(res, `/app?${searchParams}`)
return searchParams
}

// Successful LTI deep linking launch
Expand Down
18 changes: 18 additions & 0 deletions src/backend/types/idtoken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Hard coded type because the type provided by @types/ltijs is outdated
// TODO: Use type provided by ltijs instead once it is included or extend this one with additional properties
export interface IdToken {
platformContext?: {
custom?: {
id?: string
}
resource?: {
id?: string
title?: string
}
roles?: string[]
context?: {
title?: string
}
}
iss: string
}
46 changes: 46 additions & 0 deletions src/frontend/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import EditorLogoLight from './assets/serlo-editor-logo-light.svg'

export default function Header() {
const queryString = window.location.search
const urlParams = new URLSearchParams(queryString)
const title = urlParams.get('title') ?? 'Inhalt'

return (
<div
style={{
display: 'flex',
padding: '1rem',
alignItems: 'center',
gap: '1rem',
boxShadow: '0px 0px 10px 0px rgba(0,0,0,.22)',
marginBottom: '0.5rem',
}}
>
<img
style={{ width: '3.5rem', height: '3.5rem' }}
src={EditorLogoLight}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div>
<a
onClick={() => history.go(-1)}
style={{ color: '#007EC1', fontSize: '0.9rem', cursor: 'pointer' }}
>
<span style={{ rotate: '180deg', display: 'inline-block' }}>⮕</span>{' '}
Zurück zu Moodle
</a>{' '}
{/* {contextTitle ? (
<span style={{ color: '#666', fontSize: '0.9rem' }}>
({contextTitle})
</span>
) : null} */}
</div>
<h2 style={{ fontSize: '1.25rem' }}>{title}</h2>
</div>
</div>
)
}

export interface HeaderContent {
title: string
}
62 changes: 40 additions & 22 deletions src/frontend/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,49 @@
import Header from './Header'
import { isInIframe } from './utils/is-in-iframe'

// Centered & max-width content layout
export function Layout({ children }: { children: React.ReactNode }) {
const showHeader = !isInIframe

const maxContentWidth = '60rem'
// Leave some space for editor UI that extends beyond (plugin toolbar, drag handle, ...)
const paddingAroundContent = '3rem'
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
backgroundColor: 'white',
// Make horizontal scroll bar appear on small width. Plugin menu, plugin toolbar, ... need some space.
minWidth: '40rem',
overflowX: 'auto',
}}
>
<aside style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}></aside>
<main
<>
{showHeader ? <Header /> : null}

<div
style={{
flexGrow: 1,
flexShrink: 1,
flexBasis: maxContentWidth,
maxWidth: `min(100%, ${maxContentWidth})`,
// Leave some space for editor UI that extends beyond (plugin toolbar, drag handle, ...)
padding: '3rem',
display: 'flex',
flexDirection: 'row',
backgroundColor: 'white',
// Make horizontal scroll bar appear on small width. Plugin menu, plugin toolbar, ... need some space.
minWidth: '40rem',
overflowX: 'auto',
}}
>
{children}
</main>
<aside style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}></aside>
</div>
<aside style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}></aside>
<main
style={{
flexGrow: 1,
flexShrink: 1,
flexBasis: maxContentWidth,
maxWidth: `min(100%, ${maxContentWidth})`,
paddingLeft: paddingAroundContent,
paddingRight: paddingAroundContent,
}}
>
<div
style={{
paddingTop: paddingAroundContent,
paddingBottom: paddingAroundContent,
}}
>
{children}
</div>
</main>
<aside style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}></aside>
</div>
</>
)
}
1 change: 1 addition & 0 deletions src/frontend/assets/serlo-editor-logo-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/frontend/utils/is-in-iframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// https://stackoverflow.com/a/326076
export const isInIframe = window.self !== window.top