diff --git a/.env.example b/.env.example
index a079cf34d..d0f6e8901 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,10 @@ HYDRA_PUBLIC_TOKEN_URL=http://host.docker.internal:9001 # Internal docker addres
HYDRA_CLIENT_ID=editor-dashboard-dev
HYDRA_CLIENT_SECRET=secret
+# The hosts in EDITOR_APP_HOSTS authenticate against the editor client instead,
+# which is public (no secret, PKCE) so that Profile resolves its own roles claim.
+EDITOR_HYDRA_CLIENT_ID=editor-dev
+
IDENTITY_URL=http://host.docker.internal:3002 # Internal docker address
SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX
@@ -36,6 +40,19 @@ SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX
HOST_URL=http://localhost:3009
EDITOR_PUBLIC_URL=http://classroom.localhost:3013
+# The editor-ui build that the project page loads. A URL ending in
+# `latest_version` names a file holding the current release path, which is
+# fetched and cached rather than used as-is.
+# EDITOR_WEB_COMPONENT_URL=https://staging-editor-static.raspberrypi.org/branches/main
+EDITOR_WEB_COMPONENT_URL=http://localhost:3011
+
+# Scratch projects belong to Experience CS, so the project page hands them back.
+EXPERIENCE_CS_WEB_URL=https://staging.experience-cs.org
+
+# Hosts served by the EditorApp engine. Comma separated, literal strings or
+# regexes wrapped in forward slashes (see lib/origin_parser.rb).
+EDITOR_APP_HOSTS=editor.localhost
+
PROFILE_API_KEY=test # This has to match the value set in Profile (https://github.com/RaspberryPiFoundation/profile/blob/ca10a4f360b6fe2b04be76264e03283054126b0f/.env.example#L45).
# The application is configured to log sent emails in development. If
diff --git a/AGENTS.md b/AGENTS.md
index 9358fcb0b..15868d11d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,6 +47,13 @@ docker compose up
- Routes: `config/routes.rb`. Auth: `config/initializers/omniauth.rb`, `app/helpers/authentication_helper.rb`, `app/controllers/concerns/identifiable.rb`.
- Permissions: `app/models/ability.rb`. Domain ops: `lib/concepts/**`. Models: `app/models/**`. GraphQL: `app/graphql/**`.
+## EditorApp engine (in progress)
+- The Code Editor web app is being moved out of the `editor-standalone` repo into the
+ `EditorApp` engine at `editor_app/`, served at the hosts in `EDITOR_APP_HOSTS`.
+- **Read `editor_app/PLAN.md` before working on it.** It records what is built, what is
+ left, the decisions already taken (and why), the cross-repo Hydra dependency, and bugs
+ found in the React app that must not be reintroduced.
+
## Security
- Never commit secrets (`.env`, `config/master.key`, API tokens, webhook secrets).
- `.env.example` contains placeholder values only.
diff --git a/Gemfile b/Gemfile
index 68fd5d6d0..a67ad4175 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,6 +5,10 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby file: '.tool-versions'
+source 'https://rubygems.pkg.github.com/raspberrypifoundation' do
+ gem 'design_system_rails', '~> 0.19'
+end
+
gem 'administrate', '~> 1.0.0'
gem 'administrate-field-active_storage'
gem 'aws-sdk-s3', require: false
@@ -13,6 +17,7 @@ gem 'bootsnap', require: false
gem 'cancancan', '~> 3.3'
gem 'countries'
gem 'csv', '~> 3.3'
+gem 'editor_app', path: 'editor_app'
gem 'email_validator'
gem 'faker'
gem 'faraday'
@@ -50,6 +55,7 @@ gem 'ruby-vips'
gem 'rubyzip'
gem 'sentry-rails'
gem 'statesman'
+gem 'view_component'
group :development, :test do
gem 'awesome_print'
diff --git a/Gemfile.lock b/Gemfile.lock
index ec6a75fc8..e00752c38 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -8,6 +8,13 @@ GIT
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.4)
+PATH
+ remote: editor_app
+ specs:
+ editor_app (0.1.0)
+ rails (>= 8.1)
+ view_component
+
GEM
remote: https://rubygems.org/
specs:
@@ -150,6 +157,8 @@ GEM
bigdecimal
rexml
crass (1.0.7)
+ cssbundling-rails (1.4.3)
+ railties (>= 6.0.0)
csv (3.3.6)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
@@ -525,7 +534,17 @@ GEM
snaky_hash (2.0.7)
hashie (>= 0.1.0, < 6)
version_gem (~> 1.1, >= 1.1.14)
+ sprockets (4.4.1)
+ concurrent-ruby (~> 1.1)
+ logger
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.5.2)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ sprockets (>= 3.0.0)
statesman (13.3.0)
+ stimulus-rails (1.3.4)
+ railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
time (0.4.1)
@@ -542,6 +561,15 @@ GEM
uri (1.1.1)
useragent (0.16.11)
version_gem (1.1.14)
+ view_component (4.15.0)
+ actionview (>= 7.1.0)
+ activesupport (>= 7.1.0)
+ concurrent-ruby (~> 1)
+ view_component-form (0.3.1)
+ actionview (>= 7.2.0)
+ activesupport (>= 7.2.0)
+ view_component (>= 2.34.0, < 5.0)
+ zeitwerk (~> 2.5)
webdrivers (5.3.1)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
@@ -558,6 +586,18 @@ GEM
nokogiri (~> 1.8)
zeitwerk (2.8.3)
+GEM
+ remote: https://rubygems.pkg.github.com/raspberrypifoundation/
+ specs:
+ design_system_rails (0.19.0)
+ cssbundling-rails (~> 1.2)
+ importmap-rails
+ rails (>= 7.0, < 9.0)
+ sprockets-rails (~> 3.4)
+ stimulus-rails (~> 1.2)
+ view_component (>= 2.0, < 5.0)
+ view_component-form (>= 0.2.5, < 1.0)
+
PLATFORMS
aarch64-linux
arm64-darwin-24
@@ -579,7 +619,9 @@ DEPENDENCIES
csv (~> 3.3)
database_cleaner-active_record
debug
+ design_system_rails (~> 0.19)!
dotenv-rails
+ editor_app!
email_validator
factory_bot_rails
faker
@@ -634,6 +676,7 @@ DEPENDENCIES
shoulda-matchers (~> 8.0)
simplecov
statesman
+ view_component
webdrivers
webmock
diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb
index acf2cff4d..2504d590d 100644
--- a/app/controllers/auth_controller.rb
+++ b/app/controllers/auth_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class AuthController < ApplicationController
+ LOCAL_PATH = %r{\A/(?![\\/])}
+
def callback
Rails.logger.debug { "callback: #{omniauth_params}" }
# Prevent session fixation. If the session has been initialized before
@@ -8,10 +10,9 @@ def callback
reset_session
self.current_user = User.from_omniauth request.env['omniauth.auth']
+ session[:oauth_expires_at] = request.env.dig('omniauth.auth', 'credentials', 'expires_at')
- return redirect_to admin_root_path if current_user.admin?
-
- redirect_to root_path
+ redirect_to post_login_path
end
def destroy
@@ -23,7 +24,7 @@ def destroy
return
end
- redirect_to "#{ENV.fetch('IDENTITY_URL', nil)}/logout?returnTo=#{ENV.fetch('HOST_URL', nil)}",
+ redirect_to "#{ENV.fetch('IDENTITY_URL', nil)}/logout?returnTo=#{logout_return_url}",
allow_other_host: true
end
@@ -39,6 +40,24 @@ def failure
private
+ def post_login_path
+ return login_origin if login_origin
+ return admin_root_path if current_user.admin?
+
+ root_path
+ end
+
+ def login_origin
+ origin = request.env['omniauth.origin'].to_s
+ origin if origin.match?(LOCAL_PATH)
+ end
+
+ def logout_return_url
+ return request.base_url if EditorApp.serves_host?(request.host)
+
+ ENV.fetch('HOST_URL', nil)
+ end
+
def omniauth_params
request.env['omniauth.params']
end
diff --git a/app/controllers/silent_renew_controller.rb b/app/controllers/silent_renew_controller.rb
new file mode 100644
index 000000000..30c1dc181
--- /dev/null
+++ b/app/controllers/silent_renew_controller.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class SilentRenewController < ApplicationController
+ layout false
+
+ def start
+ return head :forbidden unless current_user
+
+ session[:silent_renew_state] = SecureRandom.hex(24)
+ session[:silent_renew_verifier] = SecureRandom.hex(64)
+
+ redirect_to authorize_url, allow_other_host: true
+ end
+
+ def callback
+ state = session.delete(:silent_renew_state)
+ verifier = session.delete(:silent_renew_verifier)
+
+ @renewed = params[:code].present? && matching_state?(state) && renew(params[:code], verifier)
+ end
+
+ private
+
+ def matching_state?(state)
+ state.present? && ActiveSupport::SecurityUtils.secure_compare(params[:state].to_s, state)
+ end
+
+ def renew(code, verifier)
+ token = EditorHydraClient.oauth_client.auth_code.get_token(
+ code, redirect_uri: silent_renew_url, code_verifier: verifier
+ )
+
+ self.current_user = User.from_id_token(token.params['id_token'], token.token)
+ session[:oauth_expires_at] = token.expires_at
+ true
+ rescue OAuth2::Error => e
+ Rails.logger.info { "Silent renew failed: #{e.message}" }
+ false
+ end
+
+ def authorize_url
+ query = {
+ client_id: EditorHydraClient.client_id,
+ redirect_uri: silent_renew_url,
+ response_type: 'code',
+ scope: EditorHydraClient::SCOPE,
+ state: session[:silent_renew_state],
+ prompt: 'none',
+ code_challenge: code_challenge,
+ code_challenge_method: 'S256'
+ }
+
+ "#{EditorHydraClient.authorize_url}?#{query.to_query}"
+ end
+
+ def code_challenge
+ Base64.urlsafe_encode64(Digest::SHA2.digest(session[:silent_renew_verifier]), padding: false)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0c8957b1e..4c276c9af 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -114,6 +114,15 @@ def self.from_auth(auth)
new(args)
end
+ def self.from_id_token(id_token, access_token)
+ claims = JWT.decode(id_token, nil, false).first
+ args = claims.slice(*ATTRIBUTES)
+ args['id'] = claims['sub']
+ args['token'] = access_token
+
+ new(args)
+ end
+
def self.from_token(token:)
return nil if token.blank?
diff --git a/app/views/silent_renew/callback.html.erb b/app/views/silent_renew/callback.html.erb
new file mode 100644
index 000000000..afae5d0a7
--- /dev/null
+++ b/app/views/silent_renew/callback.html.erb
@@ -0,0 +1,21 @@
+
+
+
+ Silent renew
+ <% if @renewed %>
+ <%= render EditorApp::AuthTokenComponent.new(user: current_user, expires_at: session[:oauth_expires_at]) %>
+ <% end %>
+
+
+
+
+
diff --git a/config/application.rb b/config/application.rb
index 838921ca0..bcd6ed57e 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -55,6 +55,8 @@ class Application < Rails::Application
config.api_only = false
+ config.i18n.fallbacks = [:en]
+
config.middleware.insert_before 0, CorpMiddleware
require 'rack/content_type_default'
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 4f668a926..f43a512b9 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -82,7 +82,8 @@
# Enable DNS rebinding protection and other `Host` header attacks.
config.hosts = [
- URI.parse(ENV.fetch('HOST_URL')).host
+ URI.parse(ENV.fetch('HOST_URL')).host,
+ *EditorApp.hosts
]
# Skip DNS rebinding protection for the default health check endpoint.
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 87d82bb2d..03e99bd66 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -13,7 +13,7 @@
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
OmniAuth::Strategies::Rpi, ENV.fetch('HYDRA_CLIENT_ID', nil), ENV.fetch('HYDRA_CLIENT_SECRET', nil),
- scope: 'openid email profile roles force-consent',
+ scope: EditorHydraClient::SCOPE,
callback_path: '/auth/callback',
client_options: {
site: ENV.fetch('HYDRA_PUBLIC_URL', nil),
@@ -22,7 +22,8 @@
auth_scheme: :basic_auth
},
authorize_params: {},
- origin_param: 'returnTo'
+ origin_param: 'returnTo',
+ setup: ->(env) { EditorHydraClient.configure_strategy(env) }
)
OmniAuth.config.on_failure = AuthController.action(:failure)
diff --git a/config/routes.rb b/config/routes.rb
index 4da976888..0695c3c6a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -126,9 +126,17 @@
resource :github_webhooks, only: :create, defaults: { formats: :json }
- root to: 'auth#index'
-
post '/auth/rpi', as: 'login'
get '/auth/callback', to: 'auth#callback', as: 'callback'
get '/logout', to: 'auth#destroy', as: 'logout'
+ get '/auth/silent_renew/start', to: 'silent_renew#start', as: 'start_silent_renew'
+ get '/auth/silent_renew', to: 'silent_renew#callback', as: 'silent_renew'
+
+ constraints(->(request) { !EditorApp.serves_host?(request.host) }) do
+ root to: 'auth#index'
+ end
+
+ constraints(->(request) { EditorApp.serves_host?(request.host) }) do
+ mount EditorApp::Engine => '/'
+ end
end
diff --git a/editor_app/PLAN.md b/editor_app/PLAN.md
new file mode 100644
index 000000000..8c8f8dd35
--- /dev/null
+++ b/editor_app/PLAN.md
@@ -0,0 +1,458 @@
+# Moving the Code Editor into editor-api
+
+## Why
+
+`editor-standalone` builds two React SPAs from one shared `src/` tree: the editor
+(`apps/editor/`) and Code Classroom (`apps/classroom/`). The editor is small — a home
+page, a project index and a project show page — but it is not separable from classroom:
+`apps/editor/src/**` is only an entry point, routes table, secondary nav and OIDC
+config, while every page it renders (`src/components/LandingPage`,
+`src/components/ProjectIndex`, `src/containers/ProjectComponentLoader`, `GlobalNav`,
+`Footer`) is shared code classroom also uses. Changing the editor means reasoning about
+classroom, and vice versa.
+
+This engine gives the editor its own home. The project show page must keep working as it
+does today, initialising the `` web component from `editor-ui`. Everywhere
+else the goal is less client-side JavaScript and more content rendered on the server.
+
+## What has been done
+
+Phases 0 to 4 are complete and verified against a running server, not only in
+specs. 114 specs, lint clean. In order:
+
+| Commit | Delivered |
+|---|---|
+| `5db78ac6` | The engine itself, mounted at `/` behind `EDITOR_APP_HOSTS`; `EditorApp.serves_host?`, `EditorApp::Locale`, locale resolution, `/` → `/:locale` redirect, `OriginParser.parse` |
+| `e8dd5804` | Fallout fix from that refactor: `CorpMiddleware` specs now set `ALLOWED_ORIGINS` with ClimateControl instead of stubbing `ENV#[]` |
+| `0327b477` | Home page rendered on the server, translations for all five locales, landing page CSS and assets, `config.i18n.fallbacks` |
+| `de5bc022` | Global nav via the Stencil web component's Rails build, with real per-language links and per-form CSRF tokens |
+| `f88c2fd5` | Secondary nav as a view component, `BaseComponent`, and `/:locale/education` redirecting to Code Classroom |
+| `d61ad7ca` | Footer as a view component, with the `show_footer?` opt-out for full-viewport pages |
+| `37ed492b` | Fix for `Locales.load_locales` clobbering `I18n.available_locales` — see [Bugs found](#bugs-found-in-the-react-app--do-not-reintroduce) |
+| `a3f78f84` | The editor host logging in against the **editor** Hydra client: `EditorHydraClient`, OmniAuth `setup` swapping client by host, public client + PKCE + `:request_body`, `allow-u13-login` added to the scope |
+| `cbf0ebac` | Login returning to `omniauth.origin` behind a local-path check, the `admin_root_path` redirect demoted to a fallback, logout returning to the host it was triggered from, `session[:oauth_expires_at]` |
+| `2287c6f9` | The token bridge: `AuthTokenComponent` writes `localStorage[auth_key]` from a JSON data block in ``, and clears it when signed out |
+| `f905240b` | Silent renewal: `SilentRenewController#start`/`#callback`, `User.from_id_token`, `SessionRenewalComponent`, `session_renewal.js` scheduling a hidden iframe, and the in-place "log in again" prompt |
+| `6cd002ef` | Removal of the engine's import map, which resolved to `{"imports": {}}` and emitted a dead `import "application"` — see [No Stimulus, no Turbo](#no-stimulus-no-turbo) |
+| `db69ae47` | Project show page: `ProjectsController#show`, `ProjectComponent` rendering ``, `EditorApp::WebComponent` following `latest_version`, Scratch handed to Experience CS, Rails 404/403 pages, `project.js` |
+| `9375227e` | Project index, create, rename and delete: kaminari pagination, `ProjectList{,Item}Component`, `NewProjectDialogComponent`, `ProjectTemplate`, `dialogs.js` |
+
+What exists in the engine now:
+
+```
+lib/editor_app.rb serves_host?, hosts
+app/models/editor_app/locale.rb SUPPORTED, SELECTABLE, resolve, projects_site
+app/models/editor_app/web_component.rb script_url, latest_version indirection
+app/models/editor_app/project_template.rb starter components per project type
+app/controllers/editor_app/
+ application_controller.rb locale resolution, check_authorization, show_footer?,
+ 404/403 rescues
+ home_controller.rb locales_controller.rb education_controller.rb
+ projects_controller.rb index, show, create, update, destroy
+ errors_controller.rb /:locale/error, for editor-projectLoadFailed
+app/components/editor_app/
+ base_component.rb global_nav_component.rb secondary_nav_component.rb footer_component.rb
+ auth_token_component.rb localStorage[auth_key] bridge
+ session_renewal_component.rb renewal script + "log in again" prompt
+ project_component.rb and its attributes
+ project_list_component.rb project_list_item_component.rb new_project_dialog_component.rb
+app/helpers/editor_app/application_helper.rb editor_login_path, code_classroom_url, …
+app/views/layouts/editor_app/application.html.erb
+app/views/editor_app/home/show.html.erb
+app/views/editor_app/projects/{index,show}.html.erb
+app/views/editor_app/errors/{_error,not_found,forbidden}.html.erb
+app/assets/javascripts/editor_app/ session_renewal, project, dialogs
+app/assets/stylesheets/editor_app/ application, landing_page, secondary_nav, footer,
+ project, projects (plain CSS)
+config/locales/editor_app.{en,en-US,es-LA,fr-FR,ga-IE}.yml
+```
+
+And in the host tree: `lib/editor_hydra_client.rb`,
+`app/controllers/silent_renew_controller.rb`, `app/views/silent_renew/callback.html.erb`,
+`User.from_id_token`.
+
+Specs in the host tree:
+`spec/requests/editor_app/{mounting,home,education,project,project_index}_spec.rb`,
+`spec/requests/{auth,silent_renew}_spec.rb`, `spec/components/editor_app/*_spec.rb`,
+`spec/models/editor_app/locale_spec.rb`,
+`spec/lib/{editor_app,editor_hydra_client}_spec.rb`.
+
+### What is left
+
+| | |
+|---|---|
+| Next | Phase 5 (cutover) |
+| Blocked elsewhere | Hydra client registration for staging and production — see [Outstanding dependency](#outstanding-dependency) |
+
+**Dangling route.** `session_tokens#show` has been replaced by the silent-renew routes
+and is gone from `config/routes.rb`.
+
+**Not verified in a browser.** The project index was checked through request specs and
+its HTML read by hand, but never rendered signed-in in a running browser, because login
+on the editor host needs the Hydra editor client and a session cookie cannot easily be
+forged from the console. What is unverified is therefore the *appearance* of the index,
+the list rows and the three dialogs, not their markup. The show page, the 404 page, the
+Scratch redirect, the signed-out index redirect and every asset were verified against
+the running server.
+
+Two loose ends carried forward:
+
+- The Plausible click events on the home page buttons are still inert
+ `data-plausible-event` attributes; nothing reads them.
+- `ProjectTemplate` reproduces an asymmetry in `defaultProjects.js`: the Python starter
+ marks `main.py` as the default component, the web starter marks neither `index.html`
+ nor `style.css`. That is what the React app created, so it was ported as-is, but it
+ looks unintended and is worth confirming with whoever owns the editor.
+
+### No Stimulus, no Turbo
+
+The plan assumed both. Neither is actually available: `turbo-rails` is not in the
+Gemfile, there is no host `application.js`, and importmap-rails silently drops pins it
+cannot resolve — so the engine's import map rendered as `{"imports": {}}` with a dead
+`import "application"` on every page. Rather than add two gems, the remaining phases use
+the pattern the engine already had for `session_renewal.js`: plain scripts under
+`app/assets/javascripts/editor_app/`, served by Propshaft, included with
+`javascript_include_tag ... defer: true`.
+
+This costs little. `project.js` and `dialogs.js` are each about twenty lines. The index
+pagination is a link to `?page=2` instead of a Turbo-appended frame, and flashes are
+ordinary Rails flashes rendered through `DesignSystem::AlertComponent` instead of Turbo
+Streams. Turbo Drive would also have had to be kept away from the project page, since
+it tears down `` on navigation.
+
+## Architecture
+
+### Where it lives
+
+A mountable path-gem engine at the repo root (`gem 'editor_app', path: 'editor_app'`),
+matching how `experience-cs` does it with `scratch_editor`. Rails has no convention for
+in-repo engines; this is just what `rails plugin new --mountable` produces.
+
+The engine depends on host models (`Project`, `Ability`, `User`), so its specs live in
+the host `spec/` tree following the repo's by-type layout —
+`spec/requests/editor_app/`, `spec/components/editor_app/`, `spec/models/editor_app/`,
+`spec/lib/editor_app_spec.rb`. That is what makes RSpec infer the spec type. There is no
+dummy app.
+
+### Mounting
+
+`EditorApp.serves_host?` reads `EDITOR_APP_HOSTS`, parsed with the literal-or-regex
+convention of `OriginParser.parse` in `lib/origin_parser.rb`. In the host
+`config/routes.rb` the mount comes **last**, and the bare `root` is restricted to
+non-editor hosts so `/` reaches the engine and redirects to a locale-prefixed path:
+
+```ruby
+constraints(->(request) { !EditorApp.serves_host?(request.host) }) { root to: 'auth#index' }
+constraints(->(request) { EditorApp.serves_host?(request.host) }) { mount EditorApp::Engine => '/' }
+```
+
+Everything else is declared above, so `/api/**`, `/admin`, `/graphql`, `/auth/**` and
+`/github_webhooks` keep working on both hosts. `config/environments/production.rb` adds
+`EditorApp.hosts` to `config.hosts`, without which the editor host is rejected.
+
+### Styling, with no build step added
+
+The React SCSS reached design-system-core only through `@include typography.style-N`,
+and those mixins are pure wrappers over `--font-size-N` / `--font-weight-*` /
+`--line-height-N` custom properties. Expanding them inline lets the engine ship **plain
+CSS under Propshaft** — no Sass or Node toolchain in editor-api. Tokens come from the
+prebuilt stylesheet via `DesignSystem::STYLESHEET_URL`. Propshaft rewrites relative
+`url()` references to digested paths on its own.
+
+`design_system_rails` only offers button, alert, accordion, tag, progress bar, markdown
+and form components, so nav, footer, cards and the project list are engine-local view
+components using the same tokens.
+
+Only the **light** theme tokens are ported. The React app had a `--dark` block driven by
+a persisted Redux setting; nothing outside the editor web component toggled it on these
+pages. Dark mode on server-rendered pages is unbuilt.
+
+### Components
+
+Engine components inherit `EditorApp::BaseComponent`, which delegates route and engine
+helpers to `helpers`, because neither is otherwise reachable from a component or its
+template. Component specs must render through the engine controller or those helpers
+resolve against the host application and silently produce different URLs:
+
+```ruby
+with_controller_class(EditorApp::HomeController) { render_inline(component) }
+```
+
+### Locale handling
+
+`EditorApp::ApplicationController` resolves the locale in the order `src/utils/i18n.js`
+used: path segment, then the `i18next` cookie, then `Accept-Language`, then `en`. It
+sets `default_url_options[:locale]`, which makes engine route helpers ergonomic but
+leaks `?locale=` into host application paths — hence `editor_login_path` and
+`editor_logout_path` in `ApplicationHelper`, which pass `locale: nil` to suppress it.
+Do not `.compact` that hash; it removes the very key doing the work.
+
+The engine declares its own supported locales in `EditorApp::Locale::SUPPORTED` rather
+than inferring them from which YAML files exist. Translations exist for `en`, `en-US`,
+`es-LA`, `fr-FR` and `ga-IE` only; `config.i18n.fallbacks = [:en]` covers the rest, and
+matches the `fallbackLng` the React app set.
+
+## Remaining work
+
+### Phase 2 — Auth and the token bridge — **done**
+
+How it ended up working, and the decisions worth keeping.
+
+**Two Hydra clients, and the editor host uses the editor one.** Profile resolves the
+`roles` claim per client id (`Assignment.getUserRolesForApplication(user, clientID)` in
+`profile/app/services/account-authorization/scopes.js`), and editor-api reads that claim
+for `User#admin?`, which drives `Ability`. One shared client would give a session created
+on the public editor host the same `editor-admin` role as the admin dashboard. Distinct
+clients also sidestep `frontchannel_logout_uri` being a single URI per client, unlike
+`redirect_uris`. The admin and API pages on the editor-api host keep using
+`HYDRA_CLIENT_ID`; the editor host uses `EDITOR_HYDRA_CLIENT_ID` (`editor-dev` locally).
+
+The editor client is registered `"token_endpoint_auth_method": "none"`, and **nothing
+about the client was changed** — it is also editor-ui's browser client, which requires
+`none`. So `EditorHydraClient.configure_strategy` runs from OmniAuth's per-request
+`setup` callable, which fires on **both** the request and callback phases
+(`omniauth/strategy.rb:234` and `:269`), and switches client id, clears the secret, sets
+`pkce: true` and — this is the part that is easy to miss — sets
+`client_options[:auth_scheme]` to `:request_body`. With `:basic_auth` the `oauth2` gem
+sends `Authorization: Basic :` even when the secret is nil, and Hydra rejects client
+authentication outright for a `none` client. There is a spec asserting no Authorization
+header is produced. Mutating `strategy.options` per request is safe because
+`Strategy#call` does `dup.call!(env)` and `initialize_copy` dups the Hashie::Mash
+options deeply.
+
+A consequence accepted deliberately: `editor-admin` should not be assigned to the editor
+client in Profile, so **admins are not admins on the editor host**.
+
+**Login returns where it started.** `AuthController#callback` honours
+`request.env['omniauth.origin']` (OmniAuth is configured `origin_param: 'returnTo'`) when
+it is a path on this site — `%r{\A/(?![\\/])}`, which rejects `//host` and absolute
+URLs. `redirect_to admin_root_path if current_user.admin?` is now only the fallback when
+no origin was named. Logging out returns to `request.base_url` on editor hosts rather
+than always `HOST_URL`.
+
+**The token bridge.** `EditorApp::AuthTokenComponent` renders in ``: a
+`
+<% end %>
+
diff --git a/editor_app/app/components/editor_app/auth_token_component.rb b/editor_app/app/components/editor_app/auth_token_component.rb
new file mode 100644
index 000000000..bf9ab2a23
--- /dev/null
+++ b/editor_app/app/components/editor_app/auth_token_component.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module EditorApp
+ class AuthTokenComponent < BaseComponent
+ ELEMENT_ID = 'editor-app-auth-user'
+
+ attr_reader :user, :expires_at
+
+ delegate :auth_key, to: :EditorHydraClient
+
+ def initialize(user: nil, expires_at: nil)
+ super()
+ @user = user
+ @expires_at = expires_at
+ end
+
+ def renewal_path
+ Rails.application.routes.url_helpers.start_silent_renew_path
+ end
+
+ def stored_user
+ {
+ access_token: user.token,
+ token_type: 'Bearer',
+ scope: EditorHydraClient::SCOPE,
+ expires_at:,
+ profile: profile
+ }.compact
+ end
+
+ private
+
+ def profile
+ {
+ sub: user.sub,
+ user: user.id,
+ email: user.email,
+ name: user.name,
+ nickname: user.nickname,
+ username: user.username,
+ roles: user.roles
+ }.compact
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/base_component.rb b/editor_app/app/components/editor_app/base_component.rb
new file mode 100644
index 000000000..738caf9ad
--- /dev/null
+++ b/editor_app/app/components/editor_app/base_component.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module EditorApp
+ # Engine route helpers and the engine's own helpers are reached through the
+ # view context, so they are delegated here to keep component templates
+ # readable.
+ class BaseComponent < ViewComponent::Base
+ delegate :home_path, :education_path, :error_path, :projects_path, :project_path,
+ :code_classroom_url, :projects_site_url,
+ :editor_login_path, :editor_logout_path, :editor_login_authenticity_token,
+ to: :helpers
+ end
+end
diff --git a/editor_app/app/components/editor_app/footer_component.html.erb b/editor_app/app/components/editor_app/footer_component.html.erb
new file mode 100644
index 000000000..21b5fab2c
--- /dev/null
+++ b/editor_app/app/components/editor_app/footer_component.html.erb
@@ -0,0 +1,26 @@
+
diff --git a/editor_app/app/components/editor_app/footer_component.rb b/editor_app/app/components/editor_app/footer_component.rb
new file mode 100644
index 000000000..b411ca420
--- /dev/null
+++ b/editor_app/app/components/editor_app/footer_component.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module EditorApp
+ class FooterComponent < BaseComponent
+ HELP_URL = 'https://help.editor.raspberrypi.org/hc/en-us'
+ FEEDBACK_URL = 'https://form.raspberrypi.org/f/code-editor-feedback'
+ TERMS_URL = 'https://my.raspberrypi.org/code-classroom-terms'
+ SAFEGUARDING_URL = 'https://www.raspberrypi.org/safeguarding/'
+ ACCESSIBILITY_URL = 'https://www.raspberrypi.org/accessibility/'
+ PRIVACY_URL = 'https://www.raspberrypi.org/privacy/'
+ COOKIES_URL = 'https://www.raspberrypi.org/cookies/'
+ REPORT_CONCERN_URL = 'https://form.raspberrypi.org/f/report-concern-code-editor-for-education'
+
+ attr_reader :user, :show_feedback_link
+
+ def initialize(user: nil, show_feedback_link: false)
+ super()
+ @user = user
+ @show_feedback_link = show_feedback_link
+ end
+
+ def school
+ @school ||= user&.schools&.active&.first
+ end
+
+ def report_concern_url
+ "#{REPORT_CONCERN_URL}?tfa_2019=#{user.id}&tfa_2021=#{school.id}"
+ end
+
+ def links
+ [
+ [t('editor_app.footer.help'), HELP_URL],
+ ([t('editor_app.footer.feedback'), FEEDBACK_URL] if show_feedback_link),
+ [t('editor_app.footer.terms_and_conditions'), TERMS_URL],
+ [t('editor_app.footer.safeguarding'), SAFEGUARDING_URL],
+ [t('editor_app.footer.accessibility'), ACCESSIBILITY_URL],
+ [t('editor_app.footer.privacy'), PRIVACY_URL],
+ [t('editor_app.footer.cookies'), COOKIES_URL]
+ ].compact
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/global_nav_component.html.erb b/editor_app/app/components/editor_app/global_nav_component.html.erb
new file mode 100644
index 000000000..03f29edcb
--- /dev/null
+++ b/editor_app/app/components/editor_app/global_nav_component.html.erb
@@ -0,0 +1,12 @@
+
+
+>
diff --git a/editor_app/app/components/editor_app/global_nav_component.rb b/editor_app/app/components/editor_app/global_nav_component.rb
new file mode 100644
index 000000000..592d2412a
--- /dev/null
+++ b/editor_app/app/components/editor_app/global_nav_component.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module EditorApp
+ class GlobalNavComponent < BaseComponent
+ VERSION = 'v1.8.9'
+ SCRIPT_URL = "https://static.raspberrypi.org/js/global-nav-web-component/releases/#{VERSION}/rpf-global-nav.esm.js".freeze
+ FORCE_SIGNUP = 'force_signup'
+ LOCALE_PREFIX = %r{\A/[a-z]{2}(-[A-Z]{2})?(?=/|\z)}
+
+ attr_reader :current_path, :signed_in, :student
+
+ def initialize(current_path:, signed_in:, student: false)
+ super()
+ @current_path = current_path
+ @signed_in = signed_in
+ @student = student
+ end
+
+ def script_url
+ SCRIPT_URL
+ end
+
+ def locales
+ Locale::SELECTABLE.to_h { |locale, text| [locale, { url: path_in_locale(locale), text: }] }.to_json
+ end
+
+ def log_in_path
+ editor_login_path(return_to: current_path)
+ end
+
+ def sign_up_path
+ editor_login_path(return_to: current_path, login_options: FORCE_SIGNUP)
+ end
+
+ def log_out_path
+ editor_logout_path
+ end
+
+ def authenticity_token
+ editor_login_authenticity_token
+ end
+
+ private
+
+ def path_in_locale(locale)
+ remainder = current_path.sub(LOCALE_PREFIX, '')
+ "/#{locale}#{remainder unless remainder == '/'}"
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/new_project_dialog_component.html.erb b/editor_app/app/components/editor_app/new_project_dialog_component.html.erb
new file mode 100644
index 000000000..727779dbb
--- /dev/null
+++ b/editor_app/app/components/editor_app/new_project_dialog_component.html.erb
@@ -0,0 +1,48 @@
+
diff --git a/editor_app/app/components/editor_app/new_project_dialog_component.rb b/editor_app/app/components/editor_app/new_project_dialog_component.rb
new file mode 100644
index 000000000..fdf2d4c34
--- /dev/null
+++ b/editor_app/app/components/editor_app/new_project_dialog_component.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module EditorApp
+ class NewProjectDialogComponent < BaseComponent
+ DIALOG_ID = 'new-project'
+
+ delegate :types, to: :'EditorApp::ProjectTemplate'
+
+ def icon(project_type)
+ ProjectListItemComponent::ICONS.fetch(project_type)
+ end
+
+ def type_name(project_type)
+ t("editor_app.project_types.#{project_type}")
+ end
+
+ def type_description(project_type)
+ t("editor_app.projects.new_dialog.#{project_type}_description")
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/project_component.html.erb b/editor_app/app/components/editor_app/project_component.html.erb
new file mode 100644
index 000000000..775748cde
--- /dev/null
+++ b/editor_app/app/components/editor_app/project_component.html.erb
@@ -0,0 +1,14 @@
+<%= javascript_include_tag 'editor_app/project', defer: true %>
+
+ >
+
+<% unless signed_in %>
+
+<% end %>
diff --git a/editor_app/app/components/editor_app/project_component.rb b/editor_app/app/components/editor_app/project_component.rb
new file mode 100644
index 000000000..1aaa1c2cc
--- /dev/null
+++ b/editor_app/app/components/editor_app/project_component.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module EditorApp
+ # Renders the editor-ui web component, and the page-level behaviour that the
+ # component asks its host for through the events in
+ # editor-ui/src/events/WebComponentCustomEvents.js.
+ class ProjectComponent < BaseComponent
+ FEEDBACK_FORM_URL = 'https://form.raspberrypi.org/f/code-editor-feedback'
+ SIDEBAR_OPTIONS = %w[projects file images settings info].freeze
+
+ attr_reader :project, :signed_in
+
+ delegate :auth_key, to: :EditorHydraClient
+
+ def initialize(project:, signed_in: false)
+ super()
+ @project = project
+ @signed_in = signed_in
+ end
+
+ def attributes
+ {
+ auth_key:,
+ identifier: project.identifier,
+ locale: I18n.locale,
+ load_remix_disabled: true,
+ with_projectbar: true,
+ project_name_editable: true,
+ with_sidebar: true,
+ sidebar_options: SIDEBAR_OPTIONS.to_json,
+ output_split_view: true,
+ load_cache: true,
+ feedback_form_url: FEEDBACK_FORM_URL,
+ offline_enabled: false,
+ friendly_errors_enabled: friendly_errors_enabled?
+ }
+ end
+
+ def login_path
+ editor_login_path(return_to: project_path(project.identifier))
+ end
+
+ private
+
+ # The web component reads every boolean attribute as `value !== 'false'`,
+ # so a disabled feature has to be spelled out rather than left off.
+ def friendly_errors_enabled?
+ Flipper.enabled?(:friendly_errors)
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/project_list_component.html.erb b/editor_app/app/components/editor_app/project_list_component.html.erb
new file mode 100644
index 000000000..b7f99a9fe
--- /dev/null
+++ b/editor_app/app/components/editor_app/project_list_component.html.erb
@@ -0,0 +1,11 @@
+
+ <% if projects.any? %>
+
+ <% projects.each do |project| %>
+ <%= render EditorApp::ProjectListItemComponent.new(project: project) %>
+ <% end %>
+
+ <% else %>
+
<%= t('editor_app.projects.empty') %>
+ <% end %>
+
diff --git a/editor_app/app/components/editor_app/project_list_component.rb b/editor_app/app/components/editor_app/project_list_component.rb
new file mode 100644
index 000000000..c915c6bfe
--- /dev/null
+++ b/editor_app/app/components/editor_app/project_list_component.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module EditorApp
+ class ProjectListComponent < BaseComponent
+ attr_reader :projects
+
+ def initialize(projects:)
+ super()
+ @projects = projects
+ end
+ end
+end
diff --git a/editor_app/app/components/editor_app/project_list_item_component.html.erb b/editor_app/app/components/editor_app/project_list_item_component.html.erb
new file mode 100644
index 000000000..94955ce5c
--- /dev/null
+++ b/editor_app/app/components/editor_app/project_list_item_component.html.erb
@@ -0,0 +1,81 @@
+
+
+
diff --git a/editor_app/config/locales/editor_app.en-US.yml b/editor_app/config/locales/editor_app.en-US.yml
new file mode 100644
index 000000000..79f87298e
--- /dev/null
+++ b/editor_app/config/locales/editor_app.en-US.yml
@@ -0,0 +1,39 @@
+en-US:
+ editor_app:
+ home:
+ show:
+ title: "Code Editor"
+ subtitle: "Start coding, no setup required!"
+ choose_language: "What do you want to code with?"
+ python: "Python"
+ html: "HTML/CSS"
+ where_to_start: "Not sure where to start?"
+ project_python: "Python path"
+ project_html: "Web path"
+ follow_a_path_html: "Explore %{python_link} or %{html_link} on our Projects site for guided learning."
+ also_discover_html: "Teachers can also discover %{experience_cs_link}, a Scratch-based curriculum with step-by-step projects for the classroom."
+ experience_cs: "Experience CS"
+ ready_to_continue: "Ready to continue?"
+ continue_your_projects: "Continue your projects"
+ using_code_classroom: "Using Code Classroom?"
+ login_to_code_editor: "Log in to Code Editor"
+ login_as_a_student: "Log in as a student"
+ login_as_a_teacher: "Log in as a teacher"
+ go_to_classroom: "Go to Code Classroom"
+ secondary_nav:
+ code_editor: "Code Editor"
+ home: "Home"
+ education: "For Education"
+ projects: "Your Projects"
+ school: "Your School"
+ footer:
+ charity_name_and_number: "Raspberry Pi Foundation UK registered charity 1129409"
+ safeguarding_concern: "Do you have a safeguarding concern?"
+ contact_us: "Report a concern"
+ help: "Help"
+ feedback: "Feedback"
+ terms_and_conditions: "Terms & Conditions"
+ safeguarding: "Safeguarding"
+ accessibility: "Accessibility"
+ privacy: "Privacy"
+ cookies: "Cookies"
diff --git a/editor_app/config/locales/editor_app.en.yml b/editor_app/config/locales/editor_app.en.yml
new file mode 100644
index 000000000..e34f1277f
--- /dev/null
+++ b/editor_app/config/locales/editor_app.en.yml
@@ -0,0 +1,98 @@
+en:
+ editor_app:
+ meta:
+ title: Code Editor | Raspberry Pi
+ description: Code Editor, created by the Raspberry Pi Foundation
+ home:
+ show:
+ title: Code Editor
+ subtitle: Start coding, no setup required!
+ choose_language: What do you want to code with?
+ python: Python
+ html: HTML/CSS
+ where_to_start: Not sure where to start?
+ project_python: Python path
+ project_html: Web path
+ follow_a_path_html: Explore the %{python_link} or %{html_link} on our Projects site for guided learning.
+ also_discover_html: Teachers can also discover %{experience_cs_link}, a Scratch-based curriculum with step-by-step projects for the classroom.
+ experience_cs: Experience CS
+ ready_to_continue: Ready to continue?
+ continue_your_projects: Continue your projects
+ using_code_classroom: Using Code Classroom?
+ login_to_code_editor: Log in to Code Editor
+ login_as_a_student: Log in as a student
+ login_as_a_teacher: Log in as a teacher
+ go_to_classroom: Go to Code Classroom
+ ide_image_alt: A Code Editor project with instructions alongside the code
+ errors:
+ log_in: Log in to your account
+ your_projects: Your projects
+ projects_site: Explore Projects site
+ not_found:
+ heading: This page does not exist
+ text: You can start coding in a new project, or visit the Projects site for
+ cool project ideas.
+ forbidden:
+ heading: You cannot access this page
+ text: If this is your project, log in to see it. If it is not, visit the
+ Projects site for cool project ideas or start coding in a new project.
+ project_types:
+ python: Python
+ html: Web
+ code_editor_scratch: Blocks
+ scratch: Scratch
+ projects:
+ empty: No projects created yet
+ updated: Edited %{time_ago} ago
+ rename: Rename
+ rename_label: Rename project
+ delete: Delete
+ delete_label: Delete project
+ renamed: Project renamed
+ deleted: Project deleted
+ index:
+ title: Your projects
+ subtitle: Select a project to continue coding, view, or edit it.
+ home: Code Editor home
+ new_project: Create a new project
+ load_more: Load more projects
+ new_dialog:
+ heading: Create a new project
+ name_label: Project name
+ name_hint: You can always rename your project later
+ default_name: Untitled
+ type_label: What kind of project do you want to make?
+ python_description: Wide range of built-in libraries
+ html_description: HTML, CSS, and JavaScript
+ submit: Create project
+ cancel: Cancel
+ rename_dialog:
+ heading: Rename project
+ label: Change the name of your project
+ submit: Save
+ cancel: Do not save
+ delete_dialog:
+ heading: Delete project
+ text: Are you sure you want to delete %{name}?
+ submit: Delete
+ cancel: Cancel
+ session:
+ expired: You have been logged out. Log in again to carry on saving your work.
+ log_in_again: Log in again
+ secondary_nav:
+ code_editor: "Code Editor"
+ home: "Home"
+ education: "For Education"
+ projects: "Your Projects"
+ school: "Your School"
+ footer:
+ charity_name_and_number: "Raspberry Pi Foundation UK registered charity 1129409"
+ safeguarding_concern: "Do you have a safeguarding concern?"
+ contact_us: "Report a concern"
+ help: "Help"
+ feedback: "Feedback"
+ terms_and_conditions: "Terms & Conditions"
+ safeguarding: "Safeguarding"
+ accessibility: "Accessibility"
+ privacy: "Privacy"
+ cookies: "Cookies"
diff --git a/editor_app/config/locales/editor_app.es-LA.yml b/editor_app/config/locales/editor_app.es-LA.yml
new file mode 100644
index 000000000..6f0a94bf9
--- /dev/null
+++ b/editor_app/config/locales/editor_app.es-LA.yml
@@ -0,0 +1,39 @@
+es-LA:
+ editor_app:
+ home:
+ show:
+ title: "Code Editor"
+ subtitle: "¡Empieza a programar! No hace falta configurar nada"
+ choose_language: "¿En qué lenguaje quieres programar?"
+ python: "Python"
+ html: "HTML/CSS"
+ where_to_start: "¿No sabes por dónde empezar?"
+ project_python: "Ruta de Python"
+ project_html: "Ruta web"
+ follow_a_path_html: "Explora la %{python_link} o la %{html_link} en nuestro sitio de proyectos para acceder a un aprendizaje guiado."
+ also_discover_html: "Si eres docente, también puedes explorar %{experience_cs_link}, un plan de estudios basado en Scratch con proyectos paso a paso para el aula."
+ experience_cs: "Experience CS"
+ ready_to_continue: "¿Quieres continuar?"
+ continue_your_projects: "Ir a tus proyectos"
+ using_code_classroom: "¿Usas Code Classroom?"
+ login_to_code_editor: "Inicia sesión en el Code Editor"
+ login_as_a_student: "Inicia sesión como estudiante"
+ login_as_a_teacher: "Inicia sesión como docente"
+ go_to_classroom: "Ir a Code Classroom"
+ secondary_nav:
+ code_editor: "Code Editor"
+ home: "Inicio"
+ education: "For Education"
+ projects: "Tus proyectos"
+ school: "Tu escuela"
+ footer:
+ charity_name_and_number: "Fundación Raspberry Pi, organización benéfica registrada en el Reino Unido 1129409"
+ safeguarding_concern: "¿Tienes alguna inquietud en relación con la seguridad?"
+ contact_us: "Informar una inquietud"
+ help: "Ayuda"
+ feedback: "Comentarios"
+ terms_and_conditions: "Términos y condiciones"
+ safeguarding: "Protección"
+ accessibility: "Accesibilidad"
+ privacy: "Privacidad"
+ cookies: "Cookies"
diff --git a/editor_app/config/locales/editor_app.fr-FR.yml b/editor_app/config/locales/editor_app.fr-FR.yml
new file mode 100644
index 000000000..1d071b538
--- /dev/null
+++ b/editor_app/config/locales/editor_app.fr-FR.yml
@@ -0,0 +1,39 @@
+fr-FR:
+ editor_app:
+ home:
+ show:
+ title: "Code Editor"
+ subtitle: "Commencer à coder, aucune configuration requise!"
+ choose_language: "Dans quel langage voulez-vous coder?"
+ python: "Python"
+ html: "HTML/CSS"
+ where_to_start: "Vous ne savez pas par où commencer?"
+ project_python: "Chemin Python"
+ project_html: "Chemin Web"
+ follow_a_path_html: "Explorez le %{python_link} ou %{html_link} sur notre site de projets pour un apprentissage guidé."
+ also_discover_html: "Les enseignants et enseignantes peuvent aussi découvrir %{experience_cs_link}, un programme utilisant Scratch qui propose des projets détaillés à faire en classe."
+ experience_cs: "Experience CS"
+ ready_to_continue: "Vous souhaitez poursuivre?"
+ continue_your_projects: "Continuer vos projets"
+ using_code_classroom: "Vous utilisez Code Classroom?"
+ login_to_code_editor: "Se connecter au Code Editor"
+ login_as_a_student: "Se connecter en tant qu’élève"
+ login_as_a_teacher: "Se connecter en tant qu’enseignant ou enseignante"
+ go_to_classroom: "Aller à Code Classroom"
+ secondary_nav:
+ code_editor: "Code Editor"
+ home: "Accueil"
+ education: "For Education"
+ projects: "Tes projets"
+ school: "Votre école"
+ footer:
+ charity_name_and_number: "Raspberry Pi Foundation, organisme de charité enregistré au Royaume-Uni sous le numéro 1129409"
+ safeguarding_concern: "Avez-vous une préoccupation en matière de sécurité?"
+ contact_us: "Signaler une préoccupation"
+ help: "Aide"
+ feedback: "Avis"
+ terms_and_conditions: "Conditions d’utilisation"
+ safeguarding: "Protection"
+ accessibility: "Accessibilité"
+ privacy: "Protection de la vie privée"
+ cookies: "Témoins"
diff --git a/editor_app/config/locales/editor_app.ga-IE.yml b/editor_app/config/locales/editor_app.ga-IE.yml
new file mode 100644
index 000000000..3a8d1250e
--- /dev/null
+++ b/editor_app/config/locales/editor_app.ga-IE.yml
@@ -0,0 +1,39 @@
+ga-IE:
+ editor_app:
+ home:
+ show:
+ title: "Code Editor"
+ subtitle: "Tosaigh ag códú, ní gá aon rud a shocrú!"
+ choose_language: "Céard is mian leat códú leis?"
+ python: "Python"
+ html: "HTML/CSS"
+ where_to_start: "Níl tú cinnte cá háit le tosú?"
+ project_python: "Cosán Python"
+ project_html: "Cosán gréasáin"
+ follow_a_path_html: "Fiosraigh an %{python_link} nó %{html_link} ar ár suíomh Tionscadail chun foghlaim threoraithe a fháil."
+ also_discover_html: "Is féidir le múinteoirí %{experience_cs_link} a fhoghlaim freisin, curaclam bunaithe ar Scratch le tionscadail céim ar chéim don seomra ranga."
+ experience_cs: "Experience CS"
+ ready_to_continue: "Réidh le dul ar aghaidh?"
+ continue_your_projects: "Lean le do thionscadail"
+ using_code_classroom: "Ag úsáid Code Classroom?"
+ login_to_code_editor: "Logáil isteach sa Code Editor"
+ login_as_a_student: "Logáil isteach mar dhalta"
+ login_as_a_teacher: "Logáil isteach mar mhúinteoir"
+ go_to_classroom: "Téigh go Code Classroom"
+ secondary_nav:
+ code_editor: "Code Editor"
+ home: "Baile"
+ education: "For Education"
+ projects: "Do Thionscadail"
+ school: "Do Scoil"
+ footer:
+ charity_name_and_number: "Raspberry Pi Foundation UK Carthanas Cláraithe 1129409"
+ safeguarding_concern: "An bhfuil imní ort faoi chosaint?"
+ contact_us: "Tuairiscigh imní"
+ help: "Cabhair"
+ feedback: "Aiseolas"
+ terms_and_conditions: "Téarmaí agus Coinníollacha"
+ safeguarding: "Cosaint"
+ accessibility: "Inrochtaineacht"
+ privacy: "Príobháideachas"
+ cookies: "Fianáin"
diff --git a/editor_app/config/routes.rb b/editor_app/config/routes.rb
new file mode 100644
index 000000000..b4d30cc2f
--- /dev/null
+++ b/editor_app/config/routes.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+EditorApp::Engine.routes.draw do
+ scope '/:locale', locale: /[a-z]{2}(-[A-Z]{2})?/ do
+ root to: 'home#show', as: :home
+ get '/education', to: 'education#show', as: :education
+ get '/error', to: 'errors#show', as: :error
+ resources :projects, only: %i[index show create update destroy], param: :identifier
+ end
+
+ root to: 'locales#show'
+end
diff --git a/editor_app/editor_app.gemspec b/editor_app/editor_app.gemspec
new file mode 100644
index 000000000..43e4a00f0
--- /dev/null
+++ b/editor_app/editor_app.gemspec
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require_relative 'lib/editor_app/version'
+
+Gem::Specification.new do |spec|
+ spec.name = 'editor_app'
+ spec.version = EditorApp::VERSION
+ spec.authors = ['Raspberry Pi Foundation']
+ spec.summary = 'Code Editor web app'
+ spec.description = 'Server-rendered pages for the Raspberry Pi Code Editor'
+ spec.homepage = 'https://github.com/RaspberryPiFoundation/editor-api'
+ spec.license = 'MIT'
+ spec.required_ruby_version = '>= 4.0'
+
+ spec.metadata['allowed_push_host'] = 'https://rubygems.pkg.github.com/raspberrypifoundation'
+ spec.metadata['homepage_uri'] = spec.homepage
+ spec.metadata['rubygems_mfa_required'] = 'true'
+
+ spec.files = Dir['{app,config,lib}/**/*', 'README.md']
+
+ spec.add_dependency 'rails', '>= 8.1'
+ spec.add_dependency 'view_component'
+end
diff --git a/editor_app/lib/editor_app.rb b/editor_app/lib/editor_app.rb
new file mode 100644
index 000000000..c9fc0b2ec
--- /dev/null
+++ b/editor_app/lib/editor_app.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'editor_app/version'
+require 'editor_app/engine'
+
+module EditorApp
+ def self.hosts
+ OriginParser.parse(ENV.fetch('EDITOR_APP_HOSTS', nil))
+ end
+
+ def self.serves_host?(host)
+ hosts.any? do |pattern|
+ pattern.is_a?(Regexp) ? pattern.match?(host) : pattern == host
+ end
+ end
+end
diff --git a/editor_app/lib/editor_app/engine.rb b/editor_app/lib/editor_app/engine.rb
new file mode 100644
index 000000000..54ea2b314
--- /dev/null
+++ b/editor_app/lib/editor_app/engine.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'view_component'
+
+module EditorApp
+ class Engine < ::Rails::Engine
+ isolate_namespace EditorApp
+
+ config.after_initialize do
+ I18n.available_locales |= EditorApp::Locale::SUPPORTED.map(&:to_sym)
+ end
+
+ config.generators do |g|
+ g.test_framework :rspec
+ end
+ end
+end
diff --git a/editor_app/lib/editor_app/version.rb b/editor_app/lib/editor_app/version.rb
new file mode 100644
index 000000000..b6287cfec
--- /dev/null
+++ b/editor_app/lib/editor_app/version.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module EditorApp
+ VERSION = '0.1.0'
+end
diff --git a/lib/editor_hydra_client.rb b/lib/editor_hydra_client.rb
new file mode 100644
index 000000000..ba1a94719
--- /dev/null
+++ b/lib/editor_hydra_client.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module EditorHydraClient
+ SCOPE = 'openid email profile roles force-consent allow-u13-login'
+
+ class << self
+ def client_id
+ ENV.fetch('EDITOR_HYDRA_CLIENT_ID', nil)
+ end
+
+ def issuer
+ ENV.fetch('HYDRA_PUBLIC_URL', nil)
+ end
+
+ def authorize_url
+ "#{issuer}/oauth2/auth"
+ end
+
+ def token_url
+ "#{ENV.fetch('HYDRA_PUBLIC_TOKEN_URL', issuer)}/oauth2/token"
+ end
+
+ def auth_key
+ "oidc.user:#{issuer}:#{client_id}"
+ end
+
+ def oauth_client
+ OAuth2::Client.new(client_id, nil, site: issuer, token_url:, auth_scheme: :request_body)
+ end
+
+ def configure_strategy(env)
+ return unless EditorApp.serves_host?(Rack::Request.new(env).host)
+
+ strategy = env['omniauth.strategy']
+ strategy.options[:client_id] = client_id
+ strategy.options[:client_secret] = nil
+ strategy.options[:client_options][:auth_scheme] = :request_body
+ strategy.options[:pkce] = true
+ end
+ end
+end
diff --git a/lib/locales.rb b/lib/locales.rb
index bb286baf8..332db3c4d 100644
--- a/lib/locales.rb
+++ b/lib/locales.rb
@@ -18,7 +18,11 @@ def load_locales
pt-PT ro-RO ru-RU sh-ZW si-LK sk-SK sl-SI so-SO sq-AL sr-SP sv-SE sw-KE ta-IN te-IN th-TH tr-TR tt-RU uk-UA ur-PK vi-VN vls-BE xh-ZA zh-CN zh-TW
]
I18n.default_locale = :en
- I18n.available_locales = two_letter_locales + four_letter_locales
+ project_locales = two_letter_locales + four_letter_locales
+ # Union rather than assign, so locales added by engines survive whenever
+ # this runs. It runs as a side effect of autoloading UploadJob.
+ I18n.available_locales |= project_locales
+ project_locales
end
end
end
diff --git a/lib/origin_parser.rb b/lib/origin_parser.rb
index 1e6472643..c1cabc956 100644
--- a/lib/origin_parser.rb
+++ b/lib/origin_parser.rb
@@ -5,7 +5,11 @@
# regexes must be wrapped in forward slashes eg. /https?:\/\/localhost(:[0-9]*)?$/
module OriginParser
def self.parse_origins
- ENV['ALLOWED_ORIGINS']&.split(',')&.map do |origin|
+ parse(ENV.fetch('ALLOWED_ORIGINS', nil))
+ end
+
+ def self.parse(value)
+ value&.split(',')&.map do |origin|
stripped_origin = origin.strip
if stripped_origin.start_with?('/') && stripped_origin.end_with?('/')
Regexp.new(stripped_origin[1..-2])
diff --git a/spec/components/editor_app/auth_token_component_spec.rb b/spec/components/editor_app/auth_token_component_spec.rb
new file mode 100644
index 000000000..c13895730
--- /dev/null
+++ b/spec/components/editor_app/auth_token_component_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp::AuthTokenComponent, type: :component do
+ subject(:rendered) do
+ with_controller_class(EditorApp::HomeController) { render_inline(component) }
+ end
+
+ around do |example|
+ ClimateControl.modify(
+ HYDRA_PUBLIC_URL: 'https://auth.example.com',
+ EDITOR_HYDRA_CLIENT_ID: 'editor-dev'
+ ) { example.run }
+ end
+
+ let(:user) { create(:user, token: 'an-access-token') }
+ let(:component) { described_class.new(user:, expires_at: 1_800_000_000) }
+
+ def stored_user
+ JSON.parse(rendered.css("script##{described_class::ELEMENT_ID}").text)
+ end
+
+ it 'hands the editor web component the access token it reads from local storage' do
+ expect(stored_user).to include('access_token' => 'an-access-token', 'expires_at' => 1_800_000_000)
+ end
+
+ it 'identifies the user so the editor knows which projects are theirs' do
+ expect(stored_user['profile']).to include('user' => user.id, 'email' => user.email)
+ end
+
+ it 'writes the token under the key the web component is given' do
+ expect(rendered.to_html).to include('"oidc.user:https://auth.example.com:editor-dev"')
+ end
+
+ it 'tells the page when the token runs out, and where to renew it' do
+ element = rendered.css("script##{described_class::ELEMENT_ID}").first
+ expect(element.attributes.transform_values(&:value))
+ .to include('data-expires-at' => '1800000000', 'data-renewal-url' => '/auth/silent_renew/start')
+ end
+
+ context 'when nobody is signed in' do
+ let(:component) { described_class.new }
+
+ it 'writes no token' do
+ expect(rendered.css("script##{described_class::ELEMENT_ID}")).to be_empty
+ end
+
+ it 'clears any token left behind by an earlier session' do
+ expect(rendered.to_html).to include('window.localStorage.removeItem(key)')
+ end
+ end
+end
diff --git a/spec/components/editor_app/footer_component_spec.rb b/spec/components/editor_app/footer_component_spec.rb
new file mode 100644
index 000000000..830ee6118
--- /dev/null
+++ b/spec/components/editor_app/footer_component_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp::FooterComponent, type: :component do
+ subject(:rendered) do
+ with_controller_class(EditorApp::HomeController) { render_inline(component) }
+ end
+
+ let(:component) { described_class.new }
+
+ it 'names the charity behind the editor' do
+ expect(rendered.to_html).to include('registered charity 1129409')
+ end
+
+ it 'links to help, terms, safeguarding, accessibility, privacy and cookies' do
+ expect(rendered.css('.footer__link').pluck(:href)).to eq(
+ [
+ described_class::HELP_URL,
+ described_class::TERMS_URL,
+ described_class::SAFEGUARDING_URL,
+ described_class::ACCESSIBILITY_URL,
+ described_class::PRIVACY_URL,
+ described_class::COOKIES_URL
+ ]
+ )
+ end
+
+ it 'omits the feedback link unless it is asked for' do
+ expect(rendered.to_html).not_to include(described_class::FEEDBACK_URL)
+ end
+
+ context 'when the feedback link is asked for' do
+ let(:component) { described_class.new(show_feedback_link: true) }
+
+ it 'offers it' do
+ expect(rendered.css('.footer__link').pluck(:href)).to include(described_class::FEEDBACK_URL)
+ end
+ end
+
+ context 'when a user with no school is signed in' do
+ let(:component) { described_class.new(user: create(:user)) }
+
+ it 'does not offer to report a safeguarding concern, which needs a school' do
+ expect(rendered.to_html).not_to include('report-concern')
+ end
+ end
+
+ context 'when a school teacher is signed in' do
+ let(:school) { create(:school) }
+ let(:teacher) { create(:teacher, school:) }
+ let(:component) { described_class.new(user: teacher) }
+
+ it 'offers to report a safeguarding concern' do
+ expect(rendered.to_html).to include('Do you have a safeguarding concern?')
+ end
+
+ it 'identifies the user and their school to the concern form' do
+ expect(rendered.css('.footer__link').pluck(:href))
+ .to include("#{described_class::REPORT_CONCERN_URL}?tfa_2019=#{teacher.id}&tfa_2021=#{school.id}")
+ end
+ end
+end
diff --git a/spec/components/editor_app/global_nav_component_spec.rb b/spec/components/editor_app/global_nav_component_spec.rb
new file mode 100644
index 000000000..5fe95abe2
--- /dev/null
+++ b/spec/components/editor_app/global_nav_component_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp::GlobalNavComponent, type: :component do
+ subject(:rendered) do
+ with_controller_class(EditorApp::HomeController) { render_inline(component) }
+ end
+
+ let(:component) { described_class.new(current_path: '/en/projects', signed_in: false) }
+
+ it 'tells the nav which locale the page is in' do
+ expect(rendered.css('rpf-global-nav').attr('locale').value).to eq('en')
+ end
+
+ it 'offers each selectable language at the equivalent path in that language' do
+ locales = JSON.parse(rendered.css('rpf-global-nav').attr('locales').value)
+
+ expect(locales).to include(
+ 'fr-FR' => { 'url' => '/fr-FR/projects', 'text' => 'Français' },
+ 'en-US' => { 'url' => '/en-US/projects', 'text' => 'English (US)' }
+ )
+ end
+
+ it 'keeps the return path so logging in comes back to the current page' do
+ expect(rendered.css('rpf-global-nav').attr('log-in-path').value)
+ .to eq('/auth/rpi?returnTo=%2Fen%2Fprojects')
+ end
+
+ it 'asks for a fresh account when signing up' do
+ expect(rendered.css('rpf-global-nav').attr('sign-up-path').value)
+ .to include('login_options=force_signup')
+ end
+
+ it 'gives the nav a token its forms can post with' do
+ expect(rendered.css('rpf-global-nav').attr('log-in-token').value).to be_present
+ end
+
+ context 'when the path has no locale prefix' do
+ let(:component) { described_class.new(current_path: '/', signed_in: false) }
+
+ it 'points each language at its own root' do
+ locales = JSON.parse(rendered.css('rpf-global-nav').attr('locales').value)
+
+ expect(locales['es-LA']['url']).to eq('/es-LA')
+ end
+ end
+
+ context 'when a school student is signed in' do
+ let(:component) { described_class.new(current_path: '/en', signed_in: true, student: true) }
+
+ it 'hides the account dropdown, which students cannot use' do
+ expect(rendered.css('rpf-global-nav').attr('hide-account-dropdown')).to be_present
+ end
+ end
+
+ context 'when a full account is signed in' do
+ let(:component) { described_class.new(current_path: '/en', signed_in: true) }
+
+ it 'leaves the account dropdown in place' do
+ expect(rendered.css('rpf-global-nav').attr('hide-account-dropdown')).to be_nil
+ end
+ end
+end
diff --git a/spec/components/editor_app/secondary_nav_component_spec.rb b/spec/components/editor_app/secondary_nav_component_spec.rb
new file mode 100644
index 000000000..ee67aab3e
--- /dev/null
+++ b/spec/components/editor_app/secondary_nav_component_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp::SecondaryNavComponent, type: :component do
+ subject(:rendered) do
+ with_controller_class(EditorApp::HomeController) { render_inline(component) }
+ end
+
+ around do |example|
+ ClimateControl.modify(EDITOR_PUBLIC_URL: 'https://classroom.example.com') { example.run }
+ end
+
+ let(:component) { described_class.new(current_path: '/en', user: nil) }
+
+ it 'links to the home page and the education page' do
+ expect(rendered.css('a').pluck(:href)).to eq(['/en', '/en/education'])
+ end
+
+ it 'marks the current page as active' do
+ expect(rendered.css('.secondary-nav__link--active').attr('href').value).to eq('/en')
+ end
+
+ context 'when the page is not the home page' do
+ let(:component) { described_class.new(current_path: '/en/projects', user: nil) }
+
+ it 'does not render, as the nav belongs to the home page' do
+ expect(rendered.to_html).to be_blank
+ end
+ end
+
+ context 'when a user with no school is signed in' do
+ let(:component) { described_class.new(current_path: '/en', user: create(:user)) }
+
+ it 'offers their projects' do
+ expect(rendered.css('a').pluck(:href)).to include('/en/projects')
+ end
+
+ it 'does not offer a school they are not part of' do
+ expect(rendered.to_html).not_to include('/en/school')
+ end
+ end
+
+ context 'when a school teacher is signed in' do
+ let(:component) { described_class.new(current_path: '/en', user: create(:teacher, school: create(:school))) }
+
+ it 'offers their school in Code Classroom' do
+ expect(rendered.css('a').pluck(:href)).to include('https://classroom.example.com/en/school')
+ end
+ end
+
+ context 'when a school student is signed in' do
+ let(:component) { described_class.new(current_path: '/en', user: create(:student, school: create(:school))) }
+
+ it 'does not offer the project index, which students cannot use' do
+ expect(rendered.to_html).not_to include('/en/projects')
+ end
+ end
+end
diff --git a/spec/lib/corp_middleware_spec.rb b/spec/lib/corp_middleware_spec.rb
index aa94a2512..a182615bd 100644
--- a/spec/lib/corp_middleware_spec.rb
+++ b/spec/lib/corp_middleware_spec.rb
@@ -3,14 +3,16 @@
require 'rails_helper'
describe CorpMiddleware do
+ around do |example|
+ ClimateControl.modify(ALLOWED_ORIGINS: allowed_origins) { example.run }
+ end
+
+ before { allow(app).to receive(:call).and_return([200, {}, ['OK']]) }
+
let(:app) { instance_double(App::Application) }
let(:middleware) { described_class.new(app) }
let(:env) { { 'HTTP_HOST' => 'test.com', 'PATH_INFO' => '/rails/active_storage' } }
-
- before do
- allow(app).to receive(:call).and_return([200, {}, ['OK']])
- allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('test.com')
- end
+ let(:allowed_origins) { 'test.com' }
it 'sets the Cross-Origin-Resource-Policy header for a literal origin' do
_status, headers, _response = middleware.call(env)
@@ -24,19 +26,23 @@
expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin')
end
- it 'sets the Cross-Origin-Resource-Policy header for regex origin' do
- allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('/test\.com/')
+ context 'when the origin is allowed by a regex' do
+ let(:allowed_origins) { '/test\.com/' }
- _status, headers, _response = middleware.call(env)
+ it 'sets the Cross-Origin-Resource-Policy header' do
+ _status, headers, _response = middleware.call(env)
- expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin')
+ expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin')
+ end
end
- it 'does not set the Cross-Origin-Resource-Policy header for disallowed origins' do
- allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('other.com')
+ context 'when the origin is not allowed' do
+ let(:allowed_origins) { 'other.com' }
- _status, headers, _response = middleware.call(env)
+ it 'does not set the Cross-Origin-Resource-Policy header' do
+ _status, headers, _response = middleware.call(env)
- expect(headers).not_to have_key('Cross-Origin-Resource-Policy')
+ expect(headers).not_to have_key('Cross-Origin-Resource-Policy')
+ end
end
end
diff --git a/spec/lib/editor_app_spec.rb b/spec/lib/editor_app_spec.rb
new file mode 100644
index 000000000..615ecf906
--- /dev/null
+++ b/spec/lib/editor_app_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp do
+ describe '.serves_host?' do
+ it 'matches a literal host' do
+ ClimateControl.modify(EDITOR_APP_HOSTS: 'editor.localhost') do
+ expect(described_class.serves_host?('editor.localhost')).to be(true)
+ expect(described_class.serves_host?('editor-api.localhost')).to be(false)
+ end
+ end
+
+ it 'matches a host given as a regex' do
+ ClimateControl.modify(EDITOR_APP_HOSTS: '/^editor(-staging)?\.example\.com$/') do
+ expect(described_class.serves_host?('editor-staging.example.com')).to be(true)
+ expect(described_class.serves_host?('other.example.com')).to be(false)
+ end
+ end
+
+ it 'serves no hosts when unconfigured' do
+ ClimateControl.modify(EDITOR_APP_HOSTS: nil) do
+ expect(described_class.serves_host?('editor.localhost')).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/lib/editor_hydra_client_spec.rb b/spec/lib/editor_hydra_client_spec.rb
new file mode 100644
index 000000000..b2c4a02aa
--- /dev/null
+++ b/spec/lib/editor_hydra_client_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorHydraClient do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_HYDRA_CLIENT_ID: 'editor-dev',
+ HYDRA_PUBLIC_URL: 'https://auth.example.com',
+ HYDRA_PUBLIC_TOKEN_URL: 'https://auth-internal.example.com'
+ ) { example.run }
+ end
+
+ let(:strategy) do
+ OmniAuth::Strategies::Rpi.new(
+ nil, 'editor-dashboard-dev', 'dashboard-secret',
+ client_options: { auth_scheme: :basic_auth }, pkce: false
+ )
+ end
+
+ def env_for(url)
+ Rack::MockRequest.env_for(url).merge('omniauth.strategy' => strategy)
+ end
+
+ describe '.configure_strategy' do
+ it 'switches the editor host to the editor client' do
+ described_class.configure_strategy(env_for('http://editor.example.com/auth/rpi'))
+ expect(strategy.options[:client_id]).to eq('editor-dev')
+ end
+
+ it 'drops the client secret, which a public client must not be sent' do
+ described_class.configure_strategy(env_for('http://editor.example.com/auth/rpi'))
+ expect(strategy.options[:client_secret]).to be_nil
+ end
+
+ it 'authenticates the authorization code exchange with PKCE instead' do
+ described_class.configure_strategy(env_for('http://editor.example.com/auth/rpi'))
+ expect(strategy.options[:pkce]).to be(true)
+ end
+
+ it 'sends no Authorization header on the token request' do
+ described_class.configure_strategy(env_for('http://editor.example.com/auth/rpi'))
+ expect(token_request_params).to eq('client_id' => 'editor-dev', 'code' => 'abc')
+ end
+
+ it 'leaves other hosts on the dashboard client' do
+ described_class.configure_strategy(env_for('http://editor-api.example.com/auth/rpi'))
+ expect(strategy.options[:client_id]).to eq('editor-dashboard-dev')
+ end
+
+ it 'leaves other hosts authenticating with their client secret' do
+ described_class.configure_strategy(env_for('http://editor-api.example.com/auth/rpi'))
+ expect(token_request_params).to include(headers: { 'Authorization' => a_string_starting_with('Basic ') })
+ end
+ end
+
+ describe '.auth_key' do
+ it 'matches the key oidc-client-ts would have written for the editor client' do
+ expect(described_class.auth_key).to eq('oidc.user:https://auth.example.com:editor-dev')
+ end
+ end
+
+ describe '.token_url' do
+ it 'prefers the internal Hydra address' do
+ expect(described_class.token_url).to eq('https://auth-internal.example.com/oauth2/token')
+ end
+ end
+
+ def token_request_params
+ OAuth2::Authenticator.new(
+ strategy.options[:client_id],
+ strategy.options[:client_secret],
+ strategy.options[:client_options][:auth_scheme]
+ ).apply('code' => 'abc')
+ end
+end
diff --git a/spec/lib/locales_spec.rb b/spec/lib/locales_spec.rb
new file mode 100644
index 000000000..0c5de6ed4
--- /dev/null
+++ b/spec/lib/locales_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'locales'
+
+RSpec.describe Locales do
+ describe '.load_locales' do
+ it 'returns the locales a project may be uploaded in' do
+ expect(described_class.load_locales).to include(:en, :'fr-FR', :'ga-IE').and not_include(:'en-US')
+ end
+
+ it 'keeps locales added by engines available' do
+ I18n.available_locales |= [:'en-US']
+ described_class.load_locales
+
+ expect(I18n.available_locales).to include(:'en-US')
+ end
+ end
+end
diff --git a/spec/lib/origin_parser_spec.rb b/spec/lib/origin_parser_spec.rb
index 7c76563a7..44697a2a2 100644
--- a/spec/lib/origin_parser_spec.rb
+++ b/spec/lib/origin_parser_spec.rb
@@ -36,4 +36,12 @@
expect(described_class.parse_origins).to eq([])
end
end
+
+ describe '.parse' do
+ it 'parses a value given directly rather than from the environment' do
+ expect(described_class.parse('editor.localhost, /^editor\\.example\\.com$/')).to eq(
+ ['editor.localhost', Regexp.new('^editor\\.example\\.com$')]
+ )
+ end
+ end
end
diff --git a/spec/models/editor_app/locale_spec.rb b/spec/models/editor_app/locale_spec.rb
new file mode 100644
index 000000000..2f24c572d
--- /dev/null
+++ b/spec/models/editor_app/locale_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe EditorApp::Locale do
+ describe '.resolve' do
+ it 'prefers a supported locale from the path' do
+ expect(described_class.resolve(path: 'fr-FR', cookie: 'es-LA')).to eq('fr-FR')
+ end
+
+ it 'falls back to the cookie when the path has no locale' do
+ expect(described_class.resolve(path: nil, cookie: 'es-LA')).to eq('es-LA')
+ end
+
+ it 'falls back to the Accept-Language header when there is no path or cookie' do
+ expect(described_class.resolve(accept_language: 'de-DE,fr-FR;q=0.9')).to eq('fr-FR')
+ end
+
+ it 'defaults to English when nothing is supported' do
+ expect(described_class.resolve(path: 'de-DE', cookie: 'zz', accept_language: 'de-DE')).to eq('en')
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 528fd668d..5a8712555 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -94,6 +94,8 @@
config.include SignInStubs, type: :request
config.include SignInStubs, type: :system
+ config.include ViewComponent::TestHelpers, type: :component
+
if Bullet.enable?
config.before { Bullet.start_request }
config.after { Bullet.end_request }
diff --git a/spec/requests/auth_spec.rb b/spec/requests/auth_spec.rb
new file mode 100644
index 000000000..72dd69fbd
--- /dev/null
+++ b/spec/requests/auth_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Authentication' do
+ let(:user) { create(:user) }
+
+ describe 'POST /auth/rpi' do
+ before { stub_auth_for(user) }
+
+ it 'returns to the page the login started from' do
+ post '/auth/rpi', params: { returnTo: '/en/projects' }
+ follow_redirect!
+ expect(response).to redirect_to('/en/projects')
+ end
+
+ it 'ignores a return path pointing at another site' do
+ post '/auth/rpi', params: { returnTo: '//evil.example.com/en' }
+ follow_redirect!
+ expect(response).to redirect_to(root_path)
+ end
+
+ it 'records when the access token expires' do
+ OmniAuth.config.mock_auth[:rpi].credentials = { token: 'an-access-token', expires_at: 1_800_000_000 }
+ post '/auth/rpi'
+ follow_redirect!
+ expect(session[:oauth_expires_at]).to eq(1_800_000_000)
+ end
+
+ context 'when the user is an admin' do
+ let(:user) { create(:admin_user) }
+
+ it 'lands on the admin dashboard' do
+ post '/auth/rpi'
+ follow_redirect!
+ expect(response).to redirect_to(admin_root_path)
+ end
+
+ it 'still returns to the page the login started from' do
+ post '/auth/rpi', params: { returnTo: '/en' }
+ follow_redirect!
+ expect(response).to redirect_to('/en')
+ end
+ end
+ end
+
+ describe 'GET /logout' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ IDENTITY_URL: 'https://identity.example.com',
+ HOST_URL: 'https://editor-api.example.com'
+ ) { example.run }
+ end
+
+ it 'returns to this application' do
+ get 'https://editor-api.example.com/logout'
+ expect(response).to redirect_to('https://identity.example.com/logout?returnTo=https://editor-api.example.com')
+ end
+
+ it 'returns to the Code Editor when logging out from there' do
+ get 'https://editor.example.com/logout'
+ expect(response).to redirect_to('https://identity.example.com/logout?returnTo=https://editor.example.com')
+ end
+ end
+end
diff --git a/spec/requests/editor_app/education_spec.rb b/spec/requests/editor_app/education_spec.rb
new file mode 100644
index 000000000..3af81fff3
--- /dev/null
+++ b/spec/requests/editor_app/education_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Code Editor education page' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_PUBLIC_URL: 'https://classroom.example.com'
+ ) { example.run }
+ end
+
+ it 'sends teachers to their school in Code Classroom' do
+ get 'http://editor.example.com/en/education'
+ expect(response).to redirect_to('https://classroom.example.com/en/school')
+ end
+
+ it 'keeps the reader in their own language' do
+ get 'http://editor.example.com/fr-FR/education'
+ expect(response).to redirect_to('https://classroom.example.com/fr-FR/school')
+ end
+end
diff --git a/spec/requests/editor_app/home_spec.rb b/spec/requests/editor_app/home_spec.rb
new file mode 100644
index 000000000..1ccaac738
--- /dev/null
+++ b/spec/requests/editor_app/home_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Code Editor home page' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_PUBLIC_URL: 'https://classroom.example.com'
+ ) { example.run }
+ end
+
+ it 'responds 200 OK' do
+ get 'http://editor.example.com/en'
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'renders the global navigation' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('')
+ .and include(EditorApp::FooterComponent::FEEDBACK_URL)
+ end
+
+ it 'offers a starter project for each language' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('/en/projects/blank-python-starter')
+ .and include('/en/projects/blank-html-starter')
+ end
+
+ it 'renders the page in the locale from the path' do
+ get 'http://editor.example.com/fr-FR'
+ expect(response.body).to include('Commencer à coder, aucune configuration requise!')
+ .and include('')
+ end
+
+ it 'links to the Projects site in the equivalent locale' do
+ get 'http://editor.example.com/en-US'
+ expect(response.body).to include('https://projects.raspberrypi.org/en/pathways/python-intro')
+ end
+
+ context 'when nobody is signed in' do
+ it 'offers a way to log in to the Code Editor' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('Log in to Code Editor')
+ end
+
+ it 'sends the Code Editor login to the project index after authenticating' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include(CGI.escapeHTML('/auth/rpi?returnTo=%2Fen%2Fprojects'))
+ end
+
+ it 'has nothing to renew' do
+ get 'http://editor.example.com/en'
+ expect(response.body).not_to include('editor_app/session_renewal')
+ end
+
+ it 'offers the Code Classroom logins' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('https://classroom.example.com/auth/user_login/student')
+ .and include('https://classroom.example.com/auth/user_login/full')
+ end
+ end
+
+ context 'when a user is signed in' do
+ before { stub_sign_in(create(:user)) }
+
+ it 'does not offer the login options' do
+ get 'http://editor.example.com/en'
+ expect(response.body).not_to include('Log in to Code Editor')
+ end
+
+ it 'still offers the starter projects' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('/en/projects/blank-python-starter')
+ end
+
+ it 'keeps their access token fresh without navigating away from the page' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('editor_app/session_renewal')
+ .and include('Log in again')
+ end
+ end
+
+ context 'when a school student is signed in' do
+ before { stub_sign_in(create(:student)) }
+
+ it 'sends them to Code Classroom rather than offering starter projects' do
+ get 'http://editor.example.com/en'
+ expect(response.body).to include('https://classroom.example.com/en/school')
+ .and not_include('/en/projects/blank-python-starter')
+ end
+ end
+end
diff --git a/spec/requests/editor_app/mounting_spec.rb b/spec/requests/editor_app/mounting_spec.rb
new file mode 100644
index 000000000..3e9e53da0
--- /dev/null
+++ b/spec/requests/editor_app/mounting_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'EditorApp engine mounting' do
+ around do |example|
+ ClimateControl.modify(EDITOR_APP_HOSTS: 'editor.example.com') { example.run }
+ end
+
+ it 'redirects the root of an editor host to the default locale' do
+ get 'http://editor.example.com/'
+ expect(response).to redirect_to('http://editor.example.com/en')
+ end
+
+ it 'honours the i18next cookie when choosing the locale to redirect to' do
+ get 'http://editor.example.com/', headers: { 'HTTP_COOKIE' => 'i18next=fr-FR' }
+ expect(response).to redirect_to('http://editor.example.com/fr-FR')
+ end
+
+ it 'leaves the root of a non-editor host to the host application' do
+ get 'http://editor-api.example.com/'
+ expect(response.body).to include('Log in')
+ end
+
+ it 'keeps serving the API on an editor host' do
+ get 'http://editor.example.com/info/release'
+ expect(response).to have_http_status(:ok)
+ end
+end
diff --git a/spec/requests/editor_app/project_index_spec.rb b/spec/requests/editor_app/project_index_spec.rb
new file mode 100644
index 000000000..69a2a23b8
--- /dev/null
+++ b/spec/requests/editor_app/project_index_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Code Editor project index' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_PUBLIC_URL: 'https://classroom.example.com'
+ ) { example.run }
+ end
+
+ let(:user) { create(:user) }
+ let(:index_url) { 'http://editor.example.com/en/projects' }
+
+ describe 'GET #index' do
+ before { stub_sign_in(user) }
+
+ it 'lists their own projects, most recently edited first' do
+ create(:project, user_id: user.id, locale: nil, name: 'Older', updated_at: 2.days.ago)
+ create(:project, user_id: user.id, locale: nil, name: 'Newer', updated_at: 1.hour.ago)
+ get index_url
+ expect(response.body.index('Newer')).to be < response.body.index('Older')
+ end
+
+ it 'says when a project was last edited' do
+ create(:project, user_id: user.id, locale: nil, updated_at: 2.hours.ago)
+ get index_url
+ expect(response.body).to include('Edited about 2 hours ago')
+ end
+
+ it 'links to each project and offers renaming and deleting it' do
+ project = create(:project, user_id: user.id, locale: nil)
+ get index_url
+ expect(response.body).to include("/en/projects/#{project.identifier}")
+ .and include("rename-project-#{project.identifier}")
+ .and include("delete-project-#{project.identifier}")
+ end
+
+ it 'leaves out school projects, which belong to Code Classroom' do
+ school = create(:school)
+ teacher = create(:teacher, school:)
+ stub_sign_in(teacher)
+ create(:project, user_id: teacher.id, school:, locale: nil, name: 'School work')
+ create(:project, user_id: teacher.id, locale: nil, name: 'My own')
+ get index_url
+ expect(response.body).to include('My own').and not_include('School work')
+ end
+
+ it "leaves out other people's projects" do
+ create(:project, locale: nil, name: 'Not mine')
+ get index_url
+ expect(response.body).not_to include('Not mine')
+ end
+
+ it 'says so when there is nothing to list' do
+ get index_url
+ expect(response.body).to include('No projects created yet')
+ end
+
+ it 'offers the next page once there are more projects than fit on one' do
+ create_list(:project, EditorApp::ProjectsController::PAGE_SIZE + 1, user_id: user.id, locale: nil)
+ get index_url
+ expect(response.body).to include('Load more projects').and include('/en/projects?page=2')
+ end
+
+ it 'does not offer a next page when everything fits' do
+ create_list(:project, 2, user_id: user.id, locale: nil)
+ get index_url
+ expect(response.body).not_to include('Load more projects')
+ end
+
+ it 'offers the two project types the React create modal offered outside a lesson' do
+ get index_url
+ expect(response.body).to include('value="python"')
+ .and include('value="html"')
+ .and not_include('value="code_editor_scratch"')
+ end
+ end
+
+ it 'sends anybody not signed in to the home page, which offers the login' do
+ get index_url
+ expect(response).to redirect_to('/en')
+ end
+
+ context 'when a school student is signed in' do
+ before { stub_sign_in(create(:student, school: create(:school))) }
+
+ it 'refuses them, because their projects live in Code Classroom' do
+ get index_url
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
+ describe 'POST #create' do
+ before { stub_sign_in(user) }
+
+ it 'creates a Python project with an empty main.py and opens it' do
+ post index_url, params: { project: { name: 'My project', project_type: 'python' } }
+ project = Project.find_by(name: 'My project')
+ expect(project.components.map { |c| [c.name, c.extension, c.content, c.default] })
+ .to eq([['main', 'py', '', true]])
+ expect(response).to redirect_to("/en/projects/#{project.identifier}")
+ end
+
+ it 'creates a web project with an empty index.html and style.css' do
+ post index_url, params: { project: { name: 'My site', project_type: 'html' } }
+ expect(Project.find_by(name: 'My site').components.map(&:name)).to contain_exactly('index', 'style')
+ end
+
+ it 'rejects a project type it has no starter content for' do
+ post index_url, params: { project: { name: 'Blocks', project_type: 'code_editor_scratch' } }
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ describe 'PATCH #update' do
+ before { stub_sign_in(user) }
+
+ let(:project) { create(:project, user_id: user.id, locale: nil, name: 'Before') }
+
+ it 'renames their project and says so' do
+ patch "http://editor.example.com/en/projects/#{project.identifier}", params: { project: { name: 'After' } }
+ expect(project.reload.name).to eq('After')
+ expect(response).to redirect_to('/en/projects')
+ follow_redirect!
+ expect(response.body).to include('Project renamed')
+ end
+
+ it "refuses to rename somebody else's project" do
+ other = create(:project, locale: nil)
+ patch "http://editor.example.com/en/projects/#{other.identifier}", params: { project: { name: 'Mine now' } }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ before { stub_sign_in(user) }
+
+ let(:project) { create(:project, user_id: user.id, locale: nil) }
+
+ it 'deletes their project and says so' do
+ delete "http://editor.example.com/en/projects/#{project.identifier}"
+ expect(Project.exists?(project.id)).to be(false)
+ follow_redirect!
+ expect(response.body).to include('Project deleted')
+ end
+ end
+end
diff --git a/spec/requests/editor_app/project_spec.rb b/spec/requests/editor_app/project_spec.rb
new file mode 100644
index 000000000..a3157c47d
--- /dev/null
+++ b/spec/requests/editor_app/project_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Code Editor project page' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_PUBLIC_URL: 'https://classroom.example.com',
+ EDITOR_WEB_COMPONENT_URL: 'https://editor-static.example.com/v1.2.3',
+ EDITOR_HYDRA_CLIENT_ID: 'editor-test',
+ EXPERIENCE_CS_WEB_URL: 'https://experience-cs.example.com'
+ ) { example.run }
+ end
+
+ let(:starter) { create(:project, user_id: nil, locale: 'en', identifier: 'blank-python-starter') }
+
+ it 'renders a starter project to anybody, with no token needed' do
+ get "http://editor.example.com/en/projects/#{starter.identifier}"
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('')
+ end
+
+ it 'falls back to the English project when the locale has no translation' do
+ get "http://editor.example.com/fr-FR/projects/#{starter.identifier}"
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'reports an unknown project as missing' do
+ get 'http://editor.example.com/en/projects/nope-nope-nope'
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to include('This page does not exist')
+ end
+
+ it 'hands Scratch projects back to Experience CS' do
+ scratch = create(:project, user_id: nil, locale: 'en', project_type: Project::Types::SCRATCH)
+ get "http://editor.example.com/en/projects/#{scratch.identifier}"
+ expect(response).to redirect_to("https://experience-cs.example.com/projects/#{scratch.identifier}")
+ end
+
+ context "with somebody else's project" do
+ let(:project) { create(:project, locale: nil) }
+
+ it 'refuses access rather than showing the editor' do
+ get "http://editor.example.com/en/projects/#{project.identifier}"
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).to include('You cannot access this page')
+ end
+
+ it 'offers a way to log in, in case it is their own project' do
+ get "http://editor.example.com/en/projects/#{project.identifier}"
+ expect(response.body).to include(CGI.escapeHTML("/auth/rpi?returnTo=%2Fen%2Fprojects%2F#{project.identifier}"))
+ end
+ end
+
+ context 'when nobody is signed in' do
+ it 'gives the editor a form to submit when it asks for a login' do
+ get "http://editor.example.com/en/projects/#{starter.identifier}"
+ expect(response.body).to include('id="editor-app-project-login"')
+ end
+ end
+
+ context 'when the project owner is signed in' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, user_id: user.id, locale: nil) }
+
+ before { stub_sign_in(user) }
+
+ it 'renders their project' do
+ get "http://editor.example.com/en/projects/#{project.identifier}"
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'has no login form to submit, being signed in already' do
+ get "http://editor.example.com/en/projects/#{project.identifier}"
+ expect(response.body).not_to include('id="editor-app-project-login"')
+ end
+ end
+
+ context 'when the web component URL names the latest version' do
+ around do |example|
+ ClimateControl.modify(EDITOR_WEB_COMPONENT_URL: 'https://editor-static.example.com/latest_version') do
+ example.run
+ end
+ end
+
+ before { Rails.cache.clear }
+
+ it 'follows the indirection to the current release' do
+ stub_request(:get, 'https://editor-static.example.com/latest_version').to_return(body: "release/v4.5.6\n")
+ get "http://editor.example.com/en/projects/#{starter.identifier}"
+ expect(response.body).to include('https://editor-static.example.com/release/v4.5.6/web-component.js')
+ end
+ end
+end
diff --git a/spec/requests/silent_renew_spec.rb b/spec/requests/silent_renew_spec.rb
new file mode 100644
index 000000000..8fc87bd6c
--- /dev/null
+++ b/spec/requests/silent_renew_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Silent renew' do
+ around do |example|
+ ClimateControl.modify(
+ EDITOR_APP_HOSTS: 'editor.example.com',
+ EDITOR_HYDRA_CLIENT_ID: 'editor-dev',
+ HYDRA_PUBLIC_URL: 'https://auth.example.com',
+ HYDRA_PUBLIC_TOKEN_URL: 'https://auth.example.com'
+ ) { example.run }
+ end
+
+ let(:user) { create(:user) }
+ let(:token_endpoint) { 'https://auth.example.com/oauth2/token' }
+
+ def sign_in_to_editor
+ stub_auth_for(user)
+ post 'https://editor.example.com/auth/rpi'
+ follow_redirect!
+ end
+
+ def start_renewal
+ get 'https://editor.example.com/auth/silent_renew/start'
+ Rack::Utils.parse_query(URI.parse(response.location).query)
+ end
+
+ def id_token
+ JWT.encode({ 'sub' => user.id, 'email' => user.email, 'name' => user.name }, nil, 'none')
+ end
+
+ describe 'GET /auth/silent_renew/start' do
+ before { sign_in_to_editor }
+
+ it 'asks Hydra to re-authorise without interrupting the user' do
+ expect(start_renewal).to include('prompt' => 'none', 'client_id' => 'editor-dev', 'response_type' => 'code')
+ end
+
+ it 'comes back to the silent renew callback on the same origin' do
+ expect(start_renewal['redirect_uri']).to eq('https://editor.example.com/auth/silent_renew')
+ end
+
+ it 'proves possession of the authorization code with PKCE' do
+ expect(start_renewal).to include('code_challenge_method' => 'S256')
+ end
+
+ it 'refuses to renew when nobody is signed in' do
+ reset!
+ get 'https://editor.example.com/auth/silent_renew/start'
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
+ describe 'GET /auth/silent_renew' do
+ before do
+ sign_in_to_editor
+ stub_request(:post, token_endpoint).to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { access_token: 'a-fresh-token', id_token:, token_type: 'bearer', expires_in: 3600 }.to_json
+ )
+ end
+
+ def complete_renewal(state: start_renewal['state'], code: 'an-authorization-code')
+ get "https://editor.example.com/auth/silent_renew?code=#{code}&state=#{state}"
+ end
+
+ it 'writes the fresh token to local storage for the editor to pick up' do
+ complete_renewal
+ expect(response.body).to include('a-fresh-token')
+ end
+
+ it 'keeps the session token in step so server side requests use the fresh one' do
+ complete_renewal
+ expect(session[:current_user]['token']).to eq('a-fresh-token')
+ end
+
+ it 'authenticates as a public client, with the code verifier and no secret' do
+ complete_renewal
+ expect(WebMock).to have_requested(:post, token_endpoint)
+ .with { |request| request.body.include?('code_verifier') && request.headers['Authorization'].nil? }
+ end
+
+ it 'tells the page that opened it the session was renewed' do
+ complete_renewal
+ expect(response.body).to include('"editor-app:session-renewal"').and include('renewed: true')
+ end
+
+ it 'rejects a callback whose state does not match the one it issued' do
+ start_renewal
+ complete_renewal(state: 'not-the-state-we-issued')
+ expect(response.body).to include('renewed: false')
+ end
+
+ context 'when the Hydra session has gone' do
+ before do
+ stub_request(:post, token_endpoint).to_return(
+ status: 400,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { error: 'login_required' }.to_json
+ )
+ end
+
+ it 'reports the failure to the page that opened it' do
+ complete_renewal
+ expect(response.body).to include('renewed: false')
+ end
+
+ it 'leaves the stored token alone so unsaved work can still be recovered' do
+ complete_renewal
+ expect(response.body).not_to include('localStorage.removeItem')
+ end
+ end
+ end
+end
diff --git a/spec/support/negated_matchers.rb b/spec/support/negated_matchers.rb
index 575ecfc7a..f83a5bd34 100644
--- a/spec/support/negated_matchers.rb
+++ b/spec/support/negated_matchers.rb
@@ -3,3 +3,4 @@
# Negated forms of common matchers, for use inside compound expectations like
# `.to have_enqueued_job(A).and not_have_enqueued_job(B)`.
RSpec::Matchers.define_negated_matcher :not_have_enqueued_job, :have_enqueued_job
+RSpec::Matchers.define_negated_matcher :not_include, :include