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 @@ + + <%= form_with url: projects_path, method: :post, class: 'dialog__form' do %> +

<%= t('editor_app.projects.new_dialog.heading') %>

+ +

<%= t('editor_app.projects.new_dialog.name_hint') %>

+ +
+ <%= t('editor_app.projects.new_dialog.type_label') %> + <% types.each do |project_type| %> + + <% end %> +
+
+ <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.new_dialog.submit') + ) %> + <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.new_dialog.cancel'), + modifiers: ['secondary'], + formmethod: 'dialog', + formnovalidate: true + ) %> +
+ <% end %> +
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 @@ +
  • + <%= link_to project_path(project.identifier), class: 'project-list__link' do %> + <%= image_tag icon, alt: '', aria: { hidden: true }, class: 'project-list__icon' %> + + <%= project.name %> + + <%= type_name %> + <%= last_edited %> + + + <% end %> +
    + <%= render DesignSystem::ButtonComponent.new( + type: 'button', + text: t('editor_app.projects.rename'), + title: t('editor_app.projects.rename_label'), + icon: 'edit', + modifiers: ['tertiary'], + data: { dialog_open: rename_dialog_id } + ) %> + <%= render DesignSystem::ButtonComponent.new( + type: 'button', + text: t('editor_app.projects.delete'), + title: t('editor_app.projects.delete_label'), + icon: 'delete', + modifiers: ['tertiary'], + data: { dialog_open: delete_dialog_id } + ) %> +
    + + + <%= form_with url: project_path(project.identifier), method: :patch, class: 'dialog__form' do %> +

    <%= t('editor_app.projects.rename_dialog.heading') %>

    + + +
    + <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.rename_dialog.submit') + ) %> + <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.rename_dialog.cancel'), + modifiers: ['secondary'], + formmethod: 'dialog', + formnovalidate: true + ) %> +
    + <% end %> +
    + + + <%= form_with url: project_path(project.identifier), method: :delete, class: 'dialog__form' do %> +

    <%= t('editor_app.projects.delete_dialog.heading') %>

    +

    <%= t('editor_app.projects.delete_dialog.text', name: project.name) %>

    +
    + <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.delete_dialog.submit'), + modifiers: ['danger'] + ) %> + <%= render DesignSystem::ButtonComponent.new( + type: 'submit', + text: t('editor_app.projects.delete_dialog.cancel'), + modifiers: ['secondary'], + formmethod: 'dialog', + formnovalidate: true + ) %> +
    + <% end %> +
    +
  • diff --git a/editor_app/app/components/editor_app/project_list_item_component.rb b/editor_app/app/components/editor_app/project_list_item_component.rb new file mode 100644 index 000000000..daf8873cb --- /dev/null +++ b/editor_app/app/components/editor_app/project_list_item_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module EditorApp + class ProjectListItemComponent < BaseComponent + ICONS = { + Project::Types::PYTHON => 'editor_app/project_type_python.svg', + Project::Types::HTML => 'editor_app/project_type_html.svg', + Project::Types::CODE_EDITOR_SCRATCH => 'editor_app/project_type_blocks.svg', + Project::Types::SCRATCH => 'editor_app/project_type_blocks.svg' + }.freeze + + attr_reader :project + + def initialize(project:) + super() + @project = project + end + + def icon + ICONS[project.project_type] + end + + def type_name + t("editor_app.project_types.#{project.project_type}") + end + + def last_edited + t('editor_app.projects.updated', time_ago: time_ago_in_words(project.updated_at)) + end + + def rename_dialog_id + "rename-project-#{project.identifier}" + end + + def delete_dialog_id + "delete-project-#{project.identifier}" + end + end +end diff --git a/editor_app/app/components/editor_app/secondary_nav_component.html.erb b/editor_app/app/components/editor_app/secondary_nav_component.html.erb new file mode 100644 index 000000000..ef568f2db --- /dev/null +++ b/editor_app/app/components/editor_app/secondary_nav_component.html.erb @@ -0,0 +1,24 @@ + diff --git a/editor_app/app/components/editor_app/secondary_nav_component.rb b/editor_app/app/components/editor_app/secondary_nav_component.rb new file mode 100644 index 000000000..126bfb3e7 --- /dev/null +++ b/editor_app/app/components/editor_app/secondary_nav_component.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module EditorApp + class SecondaryNavComponent < BaseComponent + HOME_PATH = %r{\A/[a-z]{2}(-[A-Z]{2})?/?\z} + + attr_reader :current_path, :user + + def initialize(current_path:, user: nil) + super() + @current_path = current_path + @user = user + end + + # The React nav showed itself only on the home and education pages. + def render? + HOME_PATH.match?(current_path) + end + + def show_projects? + user.present? && !user.student? + end + + def show_school? + user.present? && user.schools.active.any? + end + + def show_separator? + show_projects? || show_school? + end + + def link_classes(path) + classes = ['secondary-nav__link'] + classes << 'secondary-nav__link--active' if current_path.chomp('/') == path.chomp('/') + classes.join(' ') + end + end +end diff --git a/editor_app/app/components/editor_app/session_renewal_component.html.erb b/editor_app/app/components/editor_app/session_renewal_component.html.erb new file mode 100644 index 000000000..0e92eab60 --- /dev/null +++ b/editor_app/app/components/editor_app/session_renewal_component.html.erb @@ -0,0 +1,8 @@ +<%= javascript_include_tag 'editor_app/session_renewal', defer: true %> + diff --git a/editor_app/app/components/editor_app/session_renewal_component.rb b/editor_app/app/components/editor_app/session_renewal_component.rb new file mode 100644 index 000000000..8ca6cb35c --- /dev/null +++ b/editor_app/app/components/editor_app/session_renewal_component.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module EditorApp + class SessionRenewalComponent < BaseComponent + attr_reader :user + + def initialize(user: nil) + super() + @user = user + end + + def render? + user.present? + end + + def login_path + editor_login_path(return_to: request.fullpath) + end + end +end diff --git a/editor_app/app/controllers/editor_app/application_controller.rb b/editor_app/app/controllers/editor_app/application_controller.rb new file mode 100644 index 000000000..444e8889c --- /dev/null +++ b/editor_app/app/controllers/editor_app/application_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module EditorApp + class ApplicationController < ::ApplicationController + layout 'editor_app/application' + + check_authorization + + around_action :switch_locale + + helper_method :show_footer? + + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + rescue_from CanCan::AccessDenied, with: :render_forbidden + + private + + def render_not_found + render 'editor_app/errors/not_found', status: :not_found + end + + def render_forbidden + render 'editor_app/errors/forbidden', status: :forbidden + end + + # The editor takes over the whole viewport, so its pages opt out. + def show_footer? + true + end + + def switch_locale(&) + I18n.with_locale(requested_locale, &) + end + + def requested_locale + Locale.resolve( + path: params[:locale], + cookie: cookies[:i18next], + accept_language: request.headers['Accept-Language'] + ) + end + + def default_url_options + { locale: I18n.locale } + end + end +end diff --git a/editor_app/app/controllers/editor_app/education_controller.rb b/editor_app/app/controllers/editor_app/education_controller.rb new file mode 100644 index 000000000..9a86a6818 --- /dev/null +++ b/editor_app/app/controllers/editor_app/education_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module EditorApp + class EducationController < ApplicationController + skip_authorization_check + + # The page this replaced existed only to tell teachers that Code Editor + # for Education is now Code Classroom. + def show + redirect_to helpers.code_classroom_url("/#{I18n.locale}/school"), allow_other_host: true + end + end +end diff --git a/editor_app/app/controllers/editor_app/errors_controller.rb b/editor_app/app/controllers/editor_app/errors_controller.rb new file mode 100644 index 000000000..d9a261504 --- /dev/null +++ b/editor_app/app/controllers/editor_app/errors_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EditorApp + # The editor web component reports an unloadable project through the + # editor-projectLoadFailed event rather than an HTTP status, so it needs + # somewhere to send the browser. + class ErrorsController < ApplicationController + skip_authorization_check + + def show + render_not_found + end + end +end diff --git a/editor_app/app/controllers/editor_app/home_controller.rb b/editor_app/app/controllers/editor_app/home_controller.rb new file mode 100644 index 000000000..1baf0ea73 --- /dev/null +++ b/editor_app/app/controllers/editor_app/home_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module EditorApp + class HomeController < ApplicationController + skip_authorization_check + + def show + @school_student = current_user&.student? + end + end +end diff --git a/editor_app/app/controllers/editor_app/locales_controller.rb b/editor_app/app/controllers/editor_app/locales_controller.rb new file mode 100644 index 000000000..e496885bd --- /dev/null +++ b/editor_app/app/controllers/editor_app/locales_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module EditorApp + class LocalesController < ApplicationController + skip_authorization_check + + def show + redirect_to home_path + end + end +end diff --git a/editor_app/app/controllers/editor_app/projects_controller.rb b/editor_app/app/controllers/editor_app/projects_controller.rb new file mode 100644 index 000000000..7bd183275 --- /dev/null +++ b/editor_app/app/controllers/editor_app/projects_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module EditorApp + class ProjectsController < ApplicationController + PAGE_SIZE = 8 + + before_action :require_sign_in, except: :show + before_action :reject_school_students, except: :show + + def index + authorize! :read, Project + @projects = own_projects.page(params[:page]).per(PAGE_SIZE) + end + + def show + @project = ProjectLoader.new(params[:identifier], [params[:locale]]).load + raise ActiveRecord::RecordNotFound unless @project + + authorize! :show, @project + return redirect_to experience_cs_project_url, allow_other_host: true if @project.project_type == Project::Types::SCRATCH + + @script_url = WebComponent.script_url + end + + def create + authorize! :create, Project + response = Project::Create.call(project_hash: new_project_hash, current_user:) + return redirect_to projects_path, alert: response[:error] if response.failure? + + redirect_to project_path(response[:project].identifier) + end + + def update + authorize! :update, project + response = Project::Update.call(project:, update_hash: { name: project_params[:name] }, current_user:) + return redirect_to projects_path, alert: response[:error] if response.failure? + + redirect_to projects_path, notice: t('editor_app.projects.renamed') + end + + def destroy + authorize! :destroy, project + project.destroy! + redirect_to projects_path, notice: t('editor_app.projects.deleted') + end + + private + + # The same filter as Types::QueryType#projects applied for the React index: + # personal projects only, never a school or lesson project. + def own_projects + Project.accessible_by(current_ability, :show) + .where(user_id: current_user.id, school_id: nil, lesson_id: nil) + .order(updated_at: :desc) + end + + def project + @project ||= own_projects.find_by!(identifier: params.expect(:identifier)) + end + + def project_params + params.expect(project: %i[name project_type]) + end + + def new_project_hash + project_type = project_params[:project_type] + raise ActionController::BadRequest unless ProjectTemplate.types.include?(project_type) + + { + name: project_params[:name], + project_type:, + user_id: current_user.id, + components: ProjectTemplate.components(project_type) + } + end + + # Experience CS owns the Scratch editor, so its projects are handed back to it. + def experience_cs_project_url + "#{ENV.fetch('EXPERIENCE_CS_WEB_URL', nil)}/projects/#{@project.identifier}" + end + + def require_sign_in + redirect_to home_path if current_user.nil? + end + + # School students work in Code Classroom, which owns their project list. + def reject_school_students + raise CanCan::AccessDenied if current_user&.student? + end + + def show_footer? + action_name != 'show' + end + end +end diff --git a/editor_app/app/helpers/editor_app/application_helper.rb b/editor_app/app/helpers/editor_app/application_helper.rb new file mode 100644 index 000000000..bbe55b2d5 --- /dev/null +++ b/editor_app/app/helpers/editor_app/application_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EditorApp + module ApplicationHelper + def code_classroom_url(path = '') + "#{ENV.fetch('EDITOR_PUBLIC_URL', nil)}#{path}" + end + + def projects_site_url(path) + "https://projects.raspberrypi.org/#{Locale.projects_site(I18n.locale)}#{path}" + end + + # Login happens in the host application, so :locale is cleared from the + # engine's default_url_options to keep it out of host application paths. + def editor_login_path(return_to:, login_options: nil) + main_app.login_path({ returnTo: return_to, login_options: }.compact.merge(locale: nil)) + end + + def editor_logout_path + main_app.logout_path(locale: nil) + end + + def editor_login_authenticity_token + form_authenticity_token(form_options: { action: main_app.login_path(locale: nil), method: 'post' }) + end + end +end diff --git a/editor_app/app/models/editor_app/locale.rb b/editor_app/app/models/editor_app/locale.rb new file mode 100644 index 000000000..5a1f62825 --- /dev/null +++ b/editor_app/app/models/editor_app/locale.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module EditorApp + module Locale + DEFAULT = 'en' + SUPPORTED = %w[en en-US es-LA fr-FR ga-IE].freeze + SELECTABLE = { + 'en' => 'English (Global)', + 'en-US' => 'English (US)', + 'es-LA' => 'Español (Latinoamericano)', + 'fr-FR' => 'Français' + }.freeze + PROJECTS_SITE_OVERRIDES = { 'en-US' => 'en' }.freeze + + def self.resolve(path: nil, cookie: nil, accept_language: nil) + supported(path) || supported(cookie) || from_accept_language(accept_language) || DEFAULT + end + + def self.supported(locale) + locale if SUPPORTED.include?(locale) + end + + def self.projects_site(locale) + PROJECTS_SITE_OVERRIDES.fetch(locale.to_s, locale.to_s) + end + + def self.from_accept_language(header) + return if header.blank? + + preferences = header.split(',').map { |entry| entry.split(';').first.to_s.strip } + preferences.find { |preference| SUPPORTED.include?(preference) } + end + end +end diff --git a/editor_app/app/models/editor_app/project_template.rb b/editor_app/app/models/editor_app/project_template.rb new file mode 100644 index 000000000..4d3736d00 --- /dev/null +++ b/editor_app/app/models/editor_app/project_template.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EditorApp + # The starter content a new project is created with, mirroring + # src/utils/defaultProjects.js in editor-standalone. Only the two types the + # React create modal offered outside a lesson are here: `code_editor_scratch` + # was gated on `forLesson`, so the project index never offered it. + module ProjectTemplate + COMPONENTS = { + Project::Types::PYTHON => [ + { name: 'main', extension: 'py', content: '', default: true } + ], + Project::Types::HTML => [ + { name: 'index', extension: 'html', content: '' }, + { name: 'style', extension: 'css', content: '' } + ] + }.freeze + + def self.types + COMPONENTS.keys + end + + def self.components(project_type) + COMPONENTS[project_type] + end + end +end diff --git a/editor_app/app/models/editor_app/web_component.rb b/editor_app/app/models/editor_app/web_component.rb new file mode 100644 index 000000000..c69541d95 --- /dev/null +++ b/editor_app/app/models/editor_app/web_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module EditorApp + # The editor web component is deployed to editor-static under a versioned + # path. A deployment can point at a `latest_version` file holding the path of + # the current release instead of naming a version, which has to be followed + # before the script URL can be built. + module WebComponent + LATEST_VERSION = 'latest_version' + CACHE_KEY = 'editor_app/web_component/latest_version' + CACHE_EXPIRY = 5.minutes + + class << self + def script_url + "#{base_url}/web-component.js" + end + + def base_url + configured = ENV.fetch('EDITOR_WEB_COMPONENT_URL', '').chomp('/') + return configured unless configured.end_with?(LATEST_VERSION) + + version = Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_EXPIRY, skip_nil: true) do + latest_version(configured) + end + version ? configured.sub(LATEST_VERSION, version) : configured + end + + private + + def latest_version(url) + response = HttpClient.new(url) { |f| f.response :raise_error }.get + response.body.strip.presence + rescue Faraday::Error => e + Sentry.capture_exception(e) + nil + end + end + end +end diff --git a/editor_app/app/views/editor_app/errors/_error.html.erb b/editor_app/app/views/editor_app/errors/_error.html.erb new file mode 100644 index 000000000..cf12d630c --- /dev/null +++ b/editor_app/app/views/editor_app/errors/_error.html.erb @@ -0,0 +1,20 @@ +
    +

    <%= heading %>

    +

    <%= text %>

    +
    + <% if current_user.nil? %> + <%= render DesignSystem::ButtonComponent.new( + type: :form, + href: editor_login_path(return_to: request.fullpath), + text: t('editor_app.errors.log_in') + ) %> + <% elsif !current_user.student? %> + <%= render DesignSystem::ButtonComponent.new(href: projects_path, text: t('editor_app.errors.your_projects')) %> + <% end %> + <%= render DesignSystem::ButtonComponent.new( + href: projects_site_url(''), + text: t('editor_app.errors.projects_site'), + modifiers: ['secondary'] + ) %> +
    +
    diff --git a/editor_app/app/views/editor_app/errors/forbidden.html.erb b/editor_app/app/views/editor_app/errors/forbidden.html.erb new file mode 100644 index 000000000..4e940e853 --- /dev/null +++ b/editor_app/app/views/editor_app/errors/forbidden.html.erb @@ -0,0 +1,2 @@ +<% content_for :title, t('.heading') %> +<%= render 'editor_app/errors/error', heading: t('.heading'), text: t('.text') %> diff --git a/editor_app/app/views/editor_app/errors/not_found.html.erb b/editor_app/app/views/editor_app/errors/not_found.html.erb new file mode 100644 index 000000000..4e940e853 --- /dev/null +++ b/editor_app/app/views/editor_app/errors/not_found.html.erb @@ -0,0 +1,2 @@ +<% content_for :title, t('.heading') %> +<%= render 'editor_app/errors/error', heading: t('.heading'), text: t('.text') %> diff --git a/editor_app/app/views/editor_app/home/show.html.erb b/editor_app/app/views/editor_app/home/show.html.erb new file mode 100644 index 000000000..b9c89c19c --- /dev/null +++ b/editor_app/app/views/editor_app/home/show.html.erb @@ -0,0 +1,74 @@ +<% content_for :head do %> + <%= stylesheet_link_tag 'editor_app/landing_page' %> +<% end %> + +
    + <% if @school_student %> +

    <%= t('.title') %>

    + <%= render DesignSystem::ButtonComponent.new( + href: code_classroom_url("/#{I18n.locale}/school"), + text: t('.go_to_classroom') + ) %> + <%= image_tag 'editor_app/ide_instructions.png', alt: t('.ide_image_alt') %> + <% else %> +
    +
    +

    <%= t('.title') %>

    +

    <%= t('.subtitle') %>

    +
    +
    +

    <%= t('.choose_language') %>

    +
    + <%= render DesignSystem::ButtonComponent.new( + href: project_path('blank-python-starter'), + data: { plausible_event: 'Start coding Python' } + ) do %> + <%= image_tag 'editor_app/python_file.svg', alt: '', aria: { hidden: true } %> + <%= t('.python') %> + <% end %> + <%= render DesignSystem::ButtonComponent.new( + href: project_path('blank-html-starter'), + data: { plausible_event: 'Start coding HTML/CSS' } + ) do %> + <%= image_tag 'editor_app/html_file.svg', alt: '', aria: { hidden: true } %> + <%= t('.html') %> + <% end %> +
    +
    +
    + +
    +
    +
    +

    <%= t('.where_to_start') %>

    +

    + <%= t('.follow_a_path_html', + python_link: link_to(t('.project_python'), projects_site_url('/pathways/python-intro'), class: 'landing-page__link'), + html_link: link_to(t('.project_html'), projects_site_url('/pathways/web-intro'), class: 'landing-page__link')) %> +

    +

    + <%= t('.also_discover_html', + experience_cs_link: link_to(t('.experience_cs'), 'https://experience-cs.org', class: 'landing-page__link')) %> +

    +
    +
    + + <% unless current_user %> +
    +
    +

    <%= t('.ready_to_continue') %>

    +

    <%= t('.continue_your_projects') %>

    + <%= button_to t('.login_to_code_editor'), + editor_login_path(return_to: projects_path), + class: 'landing-page__text-link' %> +

    <%= t('.using_code_classroom') %>

    +
    + <%= link_to t('.login_as_a_student'), code_classroom_url('/auth/user_login/student'), class: 'landing-page__text-link' %> + <%= link_to t('.login_as_a_teacher'), code_classroom_url('/auth/user_login/full'), class: 'landing-page__text-link' %> +
    +
    +
    + <% end %> +
    + <% end %> +
    diff --git a/editor_app/app/views/editor_app/projects/index.html.erb b/editor_app/app/views/editor_app/projects/index.html.erb new file mode 100644 index 000000000..cfe177e63 --- /dev/null +++ b/editor_app/app/views/editor_app/projects/index.html.erb @@ -0,0 +1,44 @@ +<% content_for :title, t('.title') %> +<% content_for :head do %> + <%= stylesheet_link_tag 'editor_app/projects' %> +<% end %> +<%= javascript_include_tag 'editor_app/dialogs', defer: true %> + +
    +
    + <%= link_to t('.home'), home_path, class: 'project-index__back' %> +
    +

    <%= t('.title') %>

    +

    <%= t('.subtitle') %>

    +
    + <%= render DesignSystem::ButtonComponent.new( + type: 'button', + text: t('.new_project'), + icon: 'add', + icon_position: 'right', + data: { dialog_open: EditorApp::NewProjectDialogComponent::DIALOG_ID } + ) %> +
    + + <% if flash[:notice].present? || flash[:alert].present? %> + <%= render DesignSystem::AlertComponent.new( + title: flash[:notice].presence || flash[:alert], + type: flash[:notice].present? ? 'success' : 'error', + class: 'project-index__flash' + ) %> + <% end %> + + <%= render EditorApp::ProjectListComponent.new(projects: @projects) %> + + <% if @projects.next_page %> +
    + <%= render DesignSystem::ButtonComponent.new( + href: projects_path(page: @projects.next_page), + text: t('.load_more'), + modifiers: ['secondary'] + ) %> +
    + <% end %> + + <%= render EditorApp::NewProjectDialogComponent.new %> +
    diff --git a/editor_app/app/views/editor_app/projects/show.html.erb b/editor_app/app/views/editor_app/projects/show.html.erb new file mode 100644 index 000000000..fbd7d7203 --- /dev/null +++ b/editor_app/app/views/editor_app/projects/show.html.erb @@ -0,0 +1,8 @@ +<% content_for :head do %> + <%= stylesheet_link_tag 'editor_app/project' %> + <%= javascript_include_tag @script_url, crossorigin: 'anonymous', defer: true %> +<% end %> + +
    + <%= render EditorApp::ProjectComponent.new(project: @project, signed_in: current_user.present?) %> +
    diff --git a/editor_app/app/views/layouts/editor_app/application.html.erb b/editor_app/app/views/layouts/editor_app/application.html.erb new file mode 100644 index 000000000..310d24724 --- /dev/null +++ b/editor_app/app/views/layouts/editor_app/application.html.erb @@ -0,0 +1,32 @@ + + + + <%= content_for(:title).presence || t('editor_app.meta.title') %> + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= render DesignSystem::Meta::GoogleFontsComponent.new(fonts: :default) %> + <%= stylesheet_link_tag DesignSystem::STYLESHEET_URL %> + <%= stylesheet_link_tag 'editor_app/application' %> + <%= stylesheet_link_tag 'editor_app/secondary_nav' %> + <%= stylesheet_link_tag 'editor_app/footer' %> + <%= content_for :head %> + <%= render EditorApp::AuthTokenComponent.new(user: current_user, expires_at: session[:oauth_expires_at]) %> + + +
    + <%= render EditorApp::GlobalNavComponent.new( + current_path: request.path, + signed_in: current_user.present?, + student: current_user&.student? || false + ) %> + <%= render EditorApp::SecondaryNavComponent.new(current_path: request.path, user: current_user) %> + <%= yield %> + <% if show_footer? %> + <%= render EditorApp::FooterComponent.new(user: current_user, show_feedback_link: true) %> + <% end %> + <%= render EditorApp::SessionRenewalComponent.new(user: current_user) %> +
    + + 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