Merge branch 'main' into bug6226

This commit is contained in:
Masha Kashirina
2026-06-12 16:00:14 +02:00
42 changed files with 754 additions and 303 deletions
@@ -4,8 +4,11 @@
# shall be synchronized to the Github repo at github.com/SAP/sailing-analytics
name: Merge main from trac@sapsailing.com:/home/trac/git
on:
schedule:
- cron: "53 * * * *" # every hour, at :53 minutes
# In the upstream repo, we currently don't want to fetch/merge any downstream branches;
# this may change again when, e.g., the Wiki at https://wiki.sapsailing.com uses a
# Git workspace that synchronizes with this upstream repo.
#schedule:
# - cron: "53 * * * *" # every hour, at :53 minutes
workflow_dispatch: {}
jobs:
merge-master-from-sapsailing-com:
@@ -0,0 +1,51 @@
# Periodically merges the upstream Eclipse repo's main branch into our
# downstream eclipse-main branch and opens (or reuses) a PR from eclipse-main
# into main. The merge/push step uses GITHUB_TOKEN (so github-actions[bot] is
# the last pusher), and the PR is opened by the eclipse-sailing-analytics-bot
# user via ECLIPSE_BOT_PAT. Because neither identity is the human reviewer,
# the "approval from someone other than the last pusher" branch protection
# rule on main is satisfied by a maintainer's own approval.
name: Merge upstream eclipse-sailing-analytics/sailing-analytics main into eclipse-main
on:
schedule:
- cron: "37 5 * * *" # daily at 05:37 UTC
workflow_dispatch: {}
jobs:
merge-upstream-main:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout eclipse-main
uses: actions/checkout@v4
with:
ref: eclipse-main
fetch-depth: 0 # full history so the merge has a real base
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Fetch upstream
run: |
git remote add upstream https://github.com/eclipse-sailing-analytics/sailing-analytics.git
git fetch upstream main
- name: Merge upstream/main into eclipse-main
run: |
git merge --no-ff upstream/main -m "Auto-merge upstream eclipse-sailing-analytics/sailing-analytics main into eclipse-main"
- name: Push eclipse-main
run: git push origin eclipse-main
- name: Open PR eclipse-main -> main (as eclipse-sailing-analytics-bot)
env:
GH_TOKEN: ${{ secrets.ECLIPSE_BOT_PAT }}
run: |
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head eclipse-main --base main --state open --json number --jq '.[0].number')
if [ -n "$existing" ]; then
echo "PR #$existing already open for eclipse-main -> main; skipping."
exit 0
fi
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head eclipse-main \
--title "Merge upstream eclipse-sailing-analytics/sailing-analytics main into main" \
--body "Automated PR opened by the daily sync workflow. The eclipse-main branch has been updated with the latest changes from upstream eclipse-sailing-analytics/sailing-analytics main. Use 'Create a merge commit' when merging (do not squash or rebase) so any '-s ours' merges on eclipse-main are respected."
-2
View File
@@ -37,7 +37,6 @@ Based on the ``docker/docker-compose.yml`` definition you should end up with thr
Try a request to [``http://127.0.0.1:8888/index.html``](http://127.0.0.1:8888/index.html) or [``http://127.0.0.1:8888/gwt/status``](http://127.0.0.1:8888/gwt/status) to see if things worked. The default login to your local administration console at [``http://127.0.0.1:8888/gwt/AdminConsole.html``](http://127.0.0.1:8888/gwt/AdminConsole.html) uses the user name ``admin`` with password ``admin``.
To use Java 25, use the ``docker-compose-25.yml`` file instead:
```
wget "https://github.com/SAP/sailing-analytics/raw/refs/heads/main/docker/docker-compose-25.yml"
docker-compose -f docker-compose-25.yml up
@@ -156,7 +155,6 @@ The build runs integration tests against [geonames.org](https://geonames.org). U
### Get the GitHub Actions build to work in your forked repository
Assign the IDs and secrets from the prerequisites to repository secrets in your forked repository as follows:
```
AWS_S3_TEST_S3ACCESSID: {your-S3-test-bucket-upload-token-ID}
AWS_S3_TEST_S3ACCESSKEY: {key-for-your-S3-token}
+9 -18
View File
@@ -5,10 +5,7 @@ if [ $# -eq 0 ]; then
echo
echo "Constructs a Hudson job for the given bugid"
echo "Example: $0 4221 [ {Bugzilla-API-Key} ]"
echo "Builds a Hudson job for bug branch bug4221, and linking to the Bugzilla bug."
echo "If a Bugzilla API Key is provided (may also be specified in the BUGZILLA_API_KEY environment"
echo "variable), it is used to add the bug summary to the build job's description."
echo "Get a Bugzilla API Key for your user account at https://bugzilla.sapsailing.com/bugzilla/userprefs.cgi?tab=apikey"
echo "Builds a Hudson job for bug branch bug4221, and linking to the Github Issue."
exit 2
fi
@@ -17,21 +14,15 @@ BUG_ID="$1"
CONFIGFILE=$(mktemp mylocalconfigXXXX.xml)
RESPONSE_HEADERS=$(mktemp responseheadersXXXX)
HUDSON_BASE_URL=https://hudson.sapsailing.com
GITHUB_ISSUES_BASE="https://github.com/eclipse-sailing-analytics/sailing-analytics/issues/"
BUGZILLA_BASE=https://bugzilla.sapsailing.com/bugzilla
COPY_TEMPLATE_JOB=CopyTemplate
OS_FOR_GSED="darwin"
if [ -n "$2" ]; then
BUGZILLA_API_KEY="$2"
fi
if [ -n "${BUGZILLA_API_KEY}" ]; then
echo "Trying to obtain bug summary/title from Bugzilla..."
BUG_SUMMARY="$( curl -s -H 'Content-Type: application/json' -H 'Accept: application/json' ${BUGZILLA_BASE}'/rest/bug/'${BUG_ID}'?Bugzilla_api_key='${BUGZILLA_API_KEY}'&include_fields=summary' | jq -r '.bugs[0].summary' )"
echo "Found: ${BUG_SUMMARY}"
else
BUG_SUMMARY=""
fi
read -p "Username: " USERNAME
read -s -p "Password: " PASSWORD
echo "Trying to obtain bug summary/title from Github..."
BUG_SUMMARY="$( curl -s -H 'Accept: application/vnd.github+json' https://api.github.com/repos/eclipse-sailing-analytics/sailing-analytics/issues/${BUG_ID} | jq -r '.title' )"
echo "Found: ${BUG_SUMMARY}"
read -p "Hudson Username: " USERNAME
read -s -p "Hudson Password: " PASSWORD
echo
COPY_TEMPLATE_CONFIG_URL="$HUDSON_BASE_URL/job/$COPY_TEMPLATE_JOB/config.xml"
curl -s -X GET $COPY_TEMPLATE_CONFIG_URL -u "$USERNAME:$PASSWORD" -o "$CONFIGFILE"
@@ -39,9 +30,9 @@ curl -s -X GET $COPY_TEMPLATE_CONFIG_URL -u "$USERNAME:$PASSWORD" -o "$CONFIGFIL
# On macosx is gnu-sed needed
if [[ "$OSTYPE" == *"$OS_FOR_GSED"* ]]; then
echo "Using gsed"
gsed -i'' -e 's|<description>..*</description>|<description>This is the CI job for \&lt;a href=\&quot;'$BUGZILLA_BASE'/show_bug.cgi?id='$BUG_ID'\&quot;\&gt;Bug '$BUG_ID'\&lt;/a\&gt; ('"${BUG_SUMMARY}"'). See its latest \&lt;a href=\&quot;/userContent/measurements.html?job=bug'$BUG_ID'\&quot;\&gt;quality and performance measurements here.\&lt;/a\&gt;</description>|' -e 's|<disabled>true</disabled>|<disabled>false</disabled>|' "$CONFIGFILE"
gsed -i'' -e 's|<description>..*</description>|<description>This is the CI job for \&lt;a href=\&quot;'${GITHUB_ISSUES_BASE}${BUG_ID}'\&quot;\&gt;Bug '$BUG_ID'\&lt;/a\&gt; ('"${BUG_SUMMARY}"'). See its latest \&lt;a href=\&quot;/userContent/measurements.html?job=bug'$BUG_ID'\&quot;\&gt;quality and performance measurements here.\&lt;/a\&gt;</description>|' -e 's|<disabled>true</disabled>|<disabled>false</disabled>|' "$CONFIGFILE"
else
sed -i -e 's|<description>..*</description>|<description>This is the CI job for \&lt;a href=\&quot;'$BUGZILLA_BASE'/show_bug.cgi?id='$BUG_ID'\&quot;\&gt;Bug '$BUG_ID'\&lt;/a\&gt; ('"${BUG_SUMMARY}"'). See its latest \&lt;a href=\&quot;/userContent/measurements.html?job=bug'$BUG_ID'\&quot;\&gt;quality and performance measurements here.\&lt;/a\&gt;</description>|' -e 's|<disabled>true</disabled>|<disabled>false</disabled>|' "$CONFIGFILE"
sed -i -e 's|<description>..*</description>|<description>This is the CI job for \&lt;a href=\&quot;'${GITHUB_ISSUES_BASE}${BUG_ID}'\&quot;\&gt;Bug '$BUG_ID'\&lt;/a\&gt; ('"${BUG_SUMMARY}"'). See its latest \&lt;a href=\&quot;/userContent/measurements.html?job=bug'$BUG_ID'\&quot;\&gt;quality and performance measurements here.\&lt;/a\&gt;</description>|' -e 's|<disabled>true</disabled>|<disabled>false</disabled>|' "$CONFIGFILE"
fi
# On macosx is gnu-sed needed
@@ -9,5 +9,5 @@ RequiredBy=multi-user.target
[Service]
Type=exec
RemainAfterExit=true
ExecStart=su - wiki -c "cd /home/wiki && rackup -p 4567 /home/wiki/config.ru"
ExecStart=su - wiki -c "cd /home/wiki && ./serve.sh"
ExecStop=/bin/kill -SIGTERM "$MAINPID"
@@ -0,0 +1,32 @@
# frozen_string_literal: true
source "https://rubygems.org"
ruby "3.2.8"
gem "gollum", "5.3.2"
gem "sinatra", "2.2.4"
gem "sinatra-contrib", "2.2.4"
gem "rack", "2.2.21"
gem "rack-protection", "2.2.4"
gem "rack-session", "~> 1.0"
gem "webrick", "1.9.1"
gem "redis", "5.4.1"
gem "redis-store", "1.11.0"
gem "redis-rack", "2.1.2"
gem "rugged", "1.9.0"
gem "github-markup", "4.0.2"
gem "kramdown", "2.5.1"
gem "kramdown-parser-gfm", "1.1.0"
gem "mustache", "1.1.1"
gem "mustache-sinatra", "2.0.0"
gem "rest-client", "2.1.0"
gem 'rufo'
@@ -0,0 +1,24 @@
# Internal Wiki
We use a Ruby based tool called Gollum for our wiki (one wiki to rule them all 🙄).
It provides a nice UI, indexing and versions all changes in Git.
We have added some authentication and access control as a sort of middleware.
This is done via the GitHub OAuth process and some APIs.
## The Process
Users navigate to `/login` (or are automatically redirected here if they are logged out and they try to access a path for modifying a resource) and are redirected to GitHub, starting the OAuth process. They have to authenticate with their chosen GitHub credentials and are then redirected back to the wiki.
If a user tries to read a resource or its history then they can access anything, but they must have push access to the Sailing Analytics repository to modify a resource (e.g. rename, create, modify).
## Installation
Run `bundle install` in the wiki home directory as the user who will start the wiki and make sure the Gemfile is present.
## Files
* The `templates` dir is necessary for adding the logout button. A custom dir can be specified by modifying the `config.ru`.
* The `app.rb` handles the auth and provides an allow list for user paths
* `config.ru` handles configuration and setup as you might guess.
* `Gemfile` all packages and versions.
* `server.sh` launches the wiki via Bundle.
## Running
First ensure the secrets specified at the start of app.rb (`ENV[<name>]`) are exported in a secrets file in the same directory. Then, as the user who owns the git repository that backs the Gollum wiki, run the `serve.sh` script.
@@ -1,99 +1,223 @@
require 'gollum/app'
require 'digest/sha1'
require "gollum/app"
require "digest/sha1"
require "logger"
require "rest-client"
require "rack/session/redis"
require "base64"
#__DIR__ = File.expand_path(File.dirname(__FILE__))
#$: << __DIR__
class App < Precious::App
User = Struct.new(:name, :email, :password_hash, :can_write)
before { authenticate! }
before /edit/ do authorize_write! ; end
before do
session['gollum.author'] = {
:name => "%s" % settings.loggedInUser,
:email => "%s" % settings.loggedInUserEmail,
}
use Rack::Session::Pool, expire_after: 3600, httponly: true #, secure: true
# use Rack::Session::Redis,
# :redis_server => "redis://127.0.0.1:6379/0",
# :expires_in => 3600
LOGGER = Logger.new("/home/wiki/wiki_log.txt")
CLIENT_ID = ENV["CLIENT_ID"]
CLIENT_SECRET = ENV["CLIENT_SECRET"]
REDIRECT_URL = "https://wiki.sapsailing.com/callback"
ACCESS_TOKEN = ENV["ACCESS_TOKEN"]
REPO_OWNER = "SAP"
REPO_NAME = "sailing-analytics"
before { check! }
before "/gollum/(edit|create|rename|delete)/*" do authorize_write end
before do
if session[:email] && session[:name]
session["gollum.author"] = {
:name => session[:name],
:email => session[:email],
}
end
end
get "/logout" do
if session[:access_token]
revoke_access_token(token)
end
session.clear()
"logged out"
end
get "/login" do
session[:oauth_state] = {
:state => SecureRandom.hex(16),
:expiry => Time.now + (60 * 5),
}
params = {
client_id: CLIENT_ID,
scope: "user:email",
state: session[:oauth_state][:state],
redirect_uri: REDIRECT_URL,
}
uri = URI::HTTPS.build(
host: "github.com",
path: "/login/oauth/authorize",
query: URI.encode_www_form(params),
)
redirect uri.to_s()
end
get "/callback" do
if session[:logged_in]
# Ensures cancel works in editor after login.
if session[:prev]
LOGGER.debug(session[:prev])
prev = session[:prev].dup
stripped_prev = prev.sub("/gollum/edit", "")
redirect stripped_prev
end
end
halt 400, "error" if params[:error]
halt 400, "Missing code" unless params[:code]
halt 403, "Old state" unless session[:oauth_state] && session[:oauth_state][:expiry] > Time.now
halt 403, "Invalid OAuth state" unless session[:oauth_state] && session[:oauth_state][:state] == params[:state]
session.delete(:oauth_state)
result = JSON.parse(RestClient.post("https://github.com/login/oauth/access_token",
{
:client_id => CLIENT_ID,
:client_secret => CLIENT_SECRET,
:code => params[:code],
:redirect_uri => REDIRECT_URL,
},
:accept => :json))
scopes = result["scope"].split(",")
if scopes.include?("user:email") && result["access_token"]
access_token = result["access_token"]
fetch_and_set_user_email(access_token)
fetch_and_set_user_name_and_id(access_token)
session[:logged_in] = true
revoke_access_token(access_token)
session.delete(:access_token)
end
if session[:prev]
redirect session[:prev].sub(/\/gollum\/(rename|delete)/, "")
end
redirect "/"
end
helpers do
def authenticate!
public_urls=IO.readlines 'public.txt'
public_urls.each {|url|
if self.env['PATH_INFO'] == url.slice(0, url.length-1)
puts "Allowing " + url
return
end
def public_path?(path)
if path == "/"
return true
end
public_starts = ["/Home", "/wiki", "/favicon.ico"]
public_starts.any? { |link| path.start_with?(link) }
end
if self.env['PATH_INFO'].start_with?('/wiki/images') ||
self.env['PATH_INFO'].start_with?('/favicon.ico')
return
end
}
if self.env['PATH_INFO'].split('/')[1] == 'gollum' && self.env['PATH_INFO'].split('/')[2] == 'assets'
def asset_path?(path)
non_page_patterns = [%r{\A/gollum/(assets|commit|history|last_commit_info).*}, %r{\A/gollum/search}, %r{\A/gollum/latest_changes\z}]
non_page_patterns.any? { |pattern| pattern.match(path) }
end
def auth_path?(path)
auth_paths = [%r{\A/gollum/(edit|create|rename|delete)/.*\z}, %r{\A/gollum/(overview|preview)}, %r{\A/gollum/create}]
auth_paths.any? { |pattern| pattern.match(path) }
end
def login_path?(path)
%r{\A/(login|callback|logout|cancel)\z}.match?(path)
end
def check!
path = env["PATH_INFO"].dup
LOGGER.debug(path)
return if login_path?(path)
return if asset_path?(path)
isPublicPath = public_path?(path)
isAuthPath = auth_path?(path)
if isPublicPath || isAuthPath
session[:prev] = path
return
end
@_auth = Rack::Auth::Basic::Request.new(request.env)
if self.env['PATH_INFO'].split('/').length >=2 &&
(self.env['PATH_INFO'].split('/')[1] != 'wiki' &&
self.env['PATH_INFO'].split('/')[2] != 'wiki' &&
self.env['PATH_INFO'].split('/')[1] != 'home' &&
self.env['PATH_INFO'].split('/')[2] != 'home' &&
self.env['PATH_INFO'].split('/')[1] != 'Home' &&
self.env['PATH_INFO'].split('/')[2] != 'Home' &&
self.env['PATH_INFO'].split('/')[1] != 'Home.md' &&
self.env['PATH_INFO'].split('/')[1] != 'search' &&
self.env['PATH_INFO'].split('/')[2] != 'search' &&
self.env['PATH_INFO'].split('/')[1] != 'edit' &&
self.env['PATH_INFO'].split('/')[2] != 'edit' &&
self.env['PATH_INFO'].split('/')[1] != 'preview' &&
self.env['PATH_INFO'].split('/')[2] != 'preview' &&
(self.env['PATH_INFO'].split('/')[1] != 'gollum' ||
(self.env['PATH_INFO'].split('/')[2] != 'create' &&
(self.env['PATH_INFO'].split('/')[2] != 'overview' || self.env['PATH_INFO'].split('/')[3] != 'wiki'))))
throw(:halt, [403, 'Forbidden - You can not access anything outside wiki/ path.'])
halt 404, "You cannot access anything outside wiki/ path."
end
def authorize_write
LOGGER.debug("Checking auth before writing")
if !session[:logged_in]
if env["PATH_INFO"].dup.match(%r{/gollum/delete/.*})
halt 401, "Unauthorized"
end
redirect "/login"
end
if @_auth.provided?
end
if @_auth.provided? && @_auth.basic? && @_auth.credentials && @user = detected_user(@_auth.credentials)
Precious::App.set(:loggedInUser, @user.name)
Precious::App.set(:loggedInUserEmail, @user.email)
return @user
else
response['WWW-Authenticate'] = %(Basic realm="Gollum Wiki")
throw(:halt, [401, "Not authorized\n"])
halt 403, "Forbidden" unless user_can_write()
end
def user_can_write()
return false unless session[:logged_in] && session[:name]
response = github_api_get("/repos/#{REPO_OWNER}/#{REPO_NAME}/collaborators/#{session[:name]}/permission",
ACCESS_TOKEN)
return false unless response
LOGGER.debug("response received")
result = JSON.parse(response)
LOGGER.debug("checking permission")
result.dig("user", "permissions", "push") == true
end
def fetch_and_set_user_email(access_token)
response = github_api_get("/user/emails", access_token)
return unless response
emails = JSON.parse(response)
if emails[0] && emails[0]["email"]
session[:email] = emails[0]["email"]
LOGGER.debug("email is #{session[:email]}")
end
end
def authorize_write!
throw(:halt, [403, "Forbidden\n"]) unless @user.can_write
end
def users
@_users ||= settings.authorized_users.map {|u| User.new(*u) }
end
def detected_user(credentials)
users.detect do |u|
[u.email, u.password_hash] ==
[credentials[0], Digest::SHA1.hexdigest(credentials[1])]
def fetch_and_set_user_name_and_id(access_token)
response = github_api_get("/user", access_token)
return unless response
user_details = JSON.parse(response)
if user_details["login"] && user_details["id"]
session[:user_id] = user_details["id"]
session[:name] = user_details["login"]
LOGGER.debug("user #{session[:email]} logged in")
end
end
end
def commit_
{
:message => params[:message],
# :name => @user.name,
:email => @user.email
}
def github_api_get(path, access_token)
uri = URI::HTTPS.build(
host: "api.github.com",
path: path,
)
RestClient.get(uri.to_s(),
{
:Authorization => "Bearer #{access_token}",
})
rescue RestClient::Unauthorized, RestClient::Forbidden
LOGGER.warn("GitHub auth failed for #{path}")
nil
rescue RestClient::ExceptionWithResponse => e
LOGGER.error("GitHub API error #{e.response.code} for #{path}")
nil
rescue StandardError => e
LOGGER.error("GitHub request failed: #{e.message}")
nil
end
def revoke_access_token(token)
uri = URI::HTTPS.build(
host: "api.github.com",
path: "/applications/#{CLIENT_ID}/token",
)
basicAuth = Base64.strict_encode64("#{CLIENT_ID}:#{CLIENT_SECRET}")
RestClient::Request.execute(
method: :delete,
url: uri.to_s(),
payload: {
:access_token => token,
}.to_json(),
headers: {
:Authorization => "Basic #{basicAuth}",
:accept => "application/vnd.github.v3+json",
},
)
end
end
end
##set author
#class Precious::App
# before do
# session['gollum.author'] = {
# :name => "%s" % settings.loggedInUser,
# :email => "%s@example.com" % settings.loggedInUser,
# }
# end
#End
@@ -28,11 +28,6 @@
#use Gollum::Auth, users, options
#
## That's it. The rest is for gollum only.
#gollum_path = "/home/wiki/gitwiki"
#wiki_options = {:universal_toc => false}
#Precious::App.set(:gollum_path, gollum_path)
#Precious::App.set(:wiki_options, wiki_options)
#run Precious::App
require 'rubygems'
require 'gollum/app'
@@ -48,7 +43,7 @@ Gollum::Page.send :remove_const, :FORMAT_NAMES if defined? Gollum::Page::FORMAT_
#}
gollum_path = "/home/wiki/gitwiki"
wiki_options = {universal_toc: false, ref: 'main'}
wiki_options = {universal_toc: false, ref: 'main', template_dir: "./templates"}
Precious::App.set(:gollum_path, gollum_path)
Precious::App.set(:wiki_options, wiki_options)
Precious::App.set(:authorized_users, YAML.load_file(File.expand_path('users.yml', File.expand_path(File.dirname(__FILE__)))))
@@ -57,12 +52,3 @@ Precious::App.set(:loggedInUserEmail, "wiki@sapsailing.com");
App.set(:default_markup, :markdown) # set your favorite markup language
run App
#require 'rubygems'
#require 'gollum/app'
#
#gollum_path = "/home/wiki/gitwiki"
#wiki_options = {:universal_toc => false}
#Precious::App.set(:gollum_path, gollum_path)
#Precious::App.set(:default_markup, :markdown) # set your favorite markup language
#Precious::App.set(:wiki_options, wiki_options)
#run Precious::App
@@ -0,0 +1,10 @@
#!/bin/bash
# kill any running gollums
kill -9 `ps axlw | grep rackup | grep wiki | awk '{ print $3; }'`
rm /home/wiki/wiki_log.txt
# start gollum as a background process
# you can pipe output to /dev/null instead, if you don't want a log
cd /home/wiki
. secrets
#nohup rackup -p 4567 /home/wiki/config.ru 1>nohup.out 2>&1 &
nohup bundle exec rackup -p 4567 /home/wiki/config.ru &
@@ -0,0 +1,65 @@
<div id="wiki-content" class="px-2 px-lg-0">
<h1 class="header-title text-center text-md-left pt-4">
{{page_header}}
</h1>
<div class="breadcrumb">{{{breadcrumb}}}</div>
<div class="{{#has_header}}has-header{{/has_header}}{{#has_footer}} has-footer{{/has_footer}}{{#has_sidebar}} has-sidebar has-{{bar_side}}bar{{/has_sidebar}}{{#has_toc}} has-toc{{/has_toc}}">
{{#has_toc}}
<div id="wiki-toc-main">
{{{toc_content}}}
</div>
{{/has_toc}}
<div id="wiki-body" class="gollum-{{format}}-content">
{{#has_header}}
<div id="wiki-header" class="gollum-{{header_format}}-content">
<div id="header-content" class="markdown-body">
{{{header_content}}}
</div>
</div>
{{/has_header}}
<div class="main-content clearfix container-lg">
<div class="markdown-body {{#header_enum?}}header-enum{{/header_enum?}} {{#has_sidebar}}float-md-{{body_side}} col-md-9{{/has_sidebar}}" {{#header_enum?}}style="--header-enum-style:{{header_enum_style}};"{{/header_enum?}}>
{{{rendered_metadata}}}
{{{content}}}
</div>
{{#has_sidebar}}
<div id="wiki-sidebar" class="Box Box--condensed float-md-{{body_side}} col-md-3">
<div id="sidebar-content" class="gollum-{{sidebar_format}}-content markdown-body px-4">
{{{sidebar_content}}}
</div>
</div>
{{/has_sidebar}}
</div>
</div>
{{#has_footer}}
<div id="wiki-footer" class="gollum-{{footer_format}}-content my-2">
<div id="footer-content" class="Box Box-condensed markdown-body px-4">
{{{footer_content}}}
</div>
</div>
{{/has_footer}}
</div>
<div id="footer" class="pt-4">
{{^historical}}
{{^preview}}
<a href="/logout">logout</a>
<p id="last-edit"><div class="dotted-spinner hidden"></div> <a id="page-info-toggle" data-pagepath="{{escaped_url_path}}">When was this page last modified?</a></p>
{{#allow_editing}}
<p>
<a id="delete-link" href="{{escaped_url_path}}" data-confirm="Are you sure you want to delete this page?"><span>Delete this Page</span></a>
</p>
{{/allow_editing}}
{{/preview}}
{{/historical}}
{{#historical}}
<p>This version of the page was edited by <b>{{author}}</b> at <time datetime="{{datetime}}" data-format="{{date_format}}">{{date}}</time>. <a href="{{full_url_path}}">View the most recent version.</a></p>
{{/historical}}
</div>
</div>
@@ -0,0 +1,88 @@
#!/bin/bash
echo "Content-Type: text/html"
echo ""
# Read secrets:
. ~/secrets
# ==== CONFIG for OAuth App wiki.sapsailing.com: ====
CLIENT_ID="${GITHUB_OAUTH_CLIENT_ID}"
CLIENT_SECRET="${GITHUB_OAUTH_CLIENT_SECRET}"
REDIRECT_URI="https://git.sapsailing.com/cgi-bin/github_oauth.sh"
# ===================================================
STATES_FILE=/tmp/github_oauth_states
# Parse QUERY_STRING for code
QUERY="$QUERY_STRING"
CODE=""
STATE_PARAM=""
IFS='&' read -ra KV <<< "$QUERY"
for pair in "${KV[@]}"; do
key="${pair%%=*}"
val="${pair#*=}"
if [ "$key" = "code" ]; then
CODE="$val"
elif [ "$key" = "state" ]; then
STATE_PARAM="$val"
fi
done
# Checking for code parameter; if not found, start the flow
if [ -z "$CODE" ]; then
# No code → show login page
STATE=$(openssl rand -hex 16)
echo "${STATE}" >>"${STATES_FILE}"
AUTH_URL="https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=read:user,user:email&state=${STATE}"
cat <<EOF
<html><head><title>GitHub OAuth Login</title></head>
<body>
<h2>Login with GitHub</h2>
<p><a href="${AUTH_URL}">Authorize this app to access your GitHub account</a></p>
</body></html>
EOF
exit 0
else
if grep -q "${STATE_PARAM}" "${STATES_FILE}"; then
sed -i '/'${STATE_PARAM}'/d' "${STATES_FILE}"
# Got a code → exchange it for an access token
TOKEN_JSON=$(curl -s -X POST https://github.com/login/oauth/access_token \
-H "Accept: application/json" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "code=${CODE}" \
-d "redirect_uri=${REDIRECT_URI}" )
ACCESS_TOKEN=$(echo "$TOKEN_JSON" | jq -r '.access_token')
if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
cat <<EOF
<html><body>
<h3>Failed to obtain access token.</h3>
<pre>${TOKEN_JSON}</pre>
</body></html>
EOF
exit 1
else
# Use token to get user info
USER_JSON=$(curl -s -H "Authorization: token ${ACCESS_TOKEN}" -H "Accept: application/vnd.github+json" https://api.github.com/user)
LOGIN=$(echo "$USER_JSON" | jq -r '.login')
cat <<EOF
<html><head><title>GitHub OAuth Success</title></head>
<body>
<h2>Welcome, ${LOGIN}!</h2>
<p>The state param ${STATE_PARAM} was found in our ${STATES_FILE}:</p>
<pre>$(cat "${STATES_FILE}")</pre>
<p>You have successfully authenticated with GitHub.</p>
<p>Your token JSON was:</p>
<pre>${TOKEN_JSON}</pre>
<p><strong>Access Token:</strong> ${ACCESS_TOKEN}</p>
<p>You can now use this token to make API calls on behalf of this user.</p>
<pre>${USER_JSON}</pre>
</body></html>
EOF
fi
else
# The ${STATE_PARAM} was not found in ${STATES_FILE}, so this may be an attack
cat <<EOF
<html><body>
<h3>Failed to find state; attack?</h3>
<pre>${STATE_PARAM}</pre>
</body></html>
EOF
exit 2
fi
fi
@@ -35,7 +35,7 @@ terminationCheck() {
}
if [[ "$#" -ne 4 ]]; then
echo "4 arguments required. Please check comment description for further details."
echo "Example usage: setup-central-reverse-proxy.sh 1.2.3.4 0OcJ1938QE5it875kjlQe7HnzQ6740jsnMEVzowjZrs= 18.170.25.225 /home/sailing/code"
echo "Example usage: setup-central-reverse-proxy.sh 1.2.3.4 0OcJ1938QE5it875kjlQe7HnzQ6740jsnMEVzowjZrs= 54.229.94.254 /home/wiki/gitwiki"
exit 2
fi
echo "Make sure the instance is in the same AZ as the existing reverse proxy, so the volumes can be switched over."
@@ -65,6 +65,7 @@ yum group install -y "Development Tools"
yum install -y ruby ruby-devel libicu libicu-devel zlib zlib-devel git cmake openssl-devel libyaml-devel
gem install gollum -v 5.3.2
gem update --system 3.5.7
su - wiki -c "cd /home/wiki && bundle install"
cd /home
# copy bugzilla
scp -o StrictHostKeyChecking=no root@sapsailing.com:/var/www/static/bugzilla-5.2.tar.gz /usr/local/src
@@ -1,11 +1,9 @@
package com.sap.sailing.declination.test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import com.sap.sailing.declination.impl.NOAAImporterForTesting;
@Disabled("Disabled because NOAA server seems down; 2026-05-19")
public class NOAADeclinationImportTest extends DeclinationImportTest<NOAAImporterForTesting> {
@BeforeEach
public void setUp() {
@@ -1,11 +1,9 @@
package com.sap.sailing.declination.test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import com.sap.sailing.declination.impl.NOAAImporter;
@Disabled("Disabled because NOAA server seems down; 2026-05-19")
public class NOAASimpleDeclinationTest extends SimpleDeclinationTest<NOAAImporter> {
@BeforeEach
public void setUp() {
@@ -66,11 +66,11 @@ public class BoatTableWrapper<S extends RefreshableSelectionModel<BoatDTO>> exte
new EntityIdentityComparator<BoatDTO>() {
@Override
public boolean representSameEntity(BoatDTO dto1, BoatDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(BoatDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
this.boatsRefresher = boatsRefresher;
@@ -286,11 +286,11 @@ public class BoatTableWrapper<S extends RefreshableSelectionModel<BoatDTO>> exte
boatsRefresher.addIfNotContainedElseReplace(updatedBoat, new EntityIdentityComparator<BoatDTO>() {
@Override
public boolean representSameEntity(BoatDTO dto1, BoatDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(BoatDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
}
@@ -93,11 +93,11 @@ public class CompetitorTableWrapper<S extends RefreshableSelectionModel<Competit
new EntityIdentityComparator<CompetitorDTO>() {
@Override
public boolean representSameEntity(CompetitorDTO dto1, CompetitorDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(CompetitorDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
this.competitorsRefresher = competitorsRefresher;
@@ -462,22 +462,22 @@ public class CompetitorTableWrapper<S extends RefreshableSelectionModel<Competit
competitorsRefresher.addIfNotContainedElseReplace(updatedCompetitor, new EntityIdentityComparator<CompetitorDTO>() {
@Override
public boolean representSameEntity(CompetitorDTO dto1, CompetitorDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(CompetitorDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
if (boatsRefresher != null) {
boatsRefresher.addIfNotContainedElseReplace(updatedCompetitor.getBoat(), new EntityIdentityComparator<BoatDTO>() {
@Override
public boolean representSameEntity(BoatDTO dto1, BoatDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(BoatDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
boatsRefresher.callAllFill();
@@ -542,11 +542,11 @@ public class CompetitorTableWrapper<S extends RefreshableSelectionModel<Competit
competitorsRefresher.addIfNotContainedElseReplace(updatedCompetitor, new EntityIdentityComparator<CompetitorDTO>() {
@Override
public boolean representSameEntity(CompetitorDTO dto1, CompetitorDTO dto2) {
return dto1.getIdAsString().equals(dto2.getIdAsString());
return Util.equalsWithNull(dto1.getIdAsString(), dto2.getIdAsString());
}
@Override
public int hashCode(CompetitorDTO t) {
return t.getIdAsString().hashCode();
return t.getIdAsString() != null ? t.getIdAsString().hashCode() : 0;
}
});
}
@@ -14,7 +14,6 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.google.gwt.cell.client.AbstractCell;
@@ -40,7 +39,6 @@ import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.view.client.ListDataProvider;
import com.google.gwt.view.client.SelectionChangeEvent;
import com.sap.sailing.domain.common.dto.CourseAreaDTO;
import com.sap.sailing.gwt.common.client.help.HelpButton;
import com.sap.sailing.gwt.common.client.help.HelpButtonResources;
@@ -178,21 +176,11 @@ public class EventListComposite extends Composite {
final Button create = buttonPanel.addCreateAction(stringMessages.actionAddEvent(), this::openCreateEventDialog);
create.ensureDebugId("CreateEventButton");
final Button remove = buttonPanel.addRemoveAction(stringMessages.remove(), refreshableEventSelectionModel, true,
() -> removeEvents(refreshableEventSelectionModel.getSelectedSet()));
remove.ensureDebugId("RemoveEventsButton");
this.refreshableEventSelectionModel.addSelectionChangeHandler(new SelectionChangeEvent.Handler() {
@Override
public void onSelectionChange(SelectionChangeEvent event) {
final Set<EventDTO> selectedEvents = refreshableEventSelectionModel.getSelectedSet();
boolean canDeleteAll = true;
for (EventDTO eventDTO : selectedEvents) {
if (!userService.hasPermission(eventDTO, DefaultActions.DELETE)) {
canDeleteAll = false;
}
}
remove.setEnabled(!selectedEvents.isEmpty() && canDeleteAll);
}
() -> {
final List<EventDTO> selected = new ArrayList<>(refreshableEventSelectionModel.getSelectedSet());
removeEvents(selected);
});
remove.ensureDebugId("RemoveEventsButton");
buttonPanel.addUnsecuredWidget(new HelpButton(HelpButtonResources.INSTANCE,
stringMessages.videoGuide(), "https://sapsailing-documentation.s3-eu-west-1.amazonaws.com/adminconsole/CreatingYourFirstEvent.mp4"));
panel.add(filterTextbox);
@@ -13,6 +13,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.google.gwt.cell.client.SafeHtmlCell;
@@ -183,17 +184,17 @@ public class IgtimiDevicesPanel extends FlowPanel implements FilterablePanelProv
devicesControlsPanel.add(busyIndicator);
buttonPanel.addUnsecuredAction(stringMessages.refresh(), () -> refreshDevices());
// setup controls
final Button removeDeviceButton = buttonPanel.addRemoveAction(stringMessages.remove(), refreshableDevicesSelectionModel,
buttonPanel.addRemoveAction(stringMessages.remove(), refreshableDevicesSelectionModel,
/* with confirmation */ true, () -> {
if (refreshableDevicesSelectionModel.getSelectedSet().size() > 0) {
if (Window.confirm(stringMessages.doYouReallyWantToRemoveTheSelectedIgtimiDevices())) {
for (IgtimiDeviceWithSecurityDTO device : refreshableDevicesSelectionModel.getSelectedSet()) {
final List<IgtimiDeviceWithSecurityDTO> selected = new ArrayList<>(refreshableDevicesSelectionModel.getSelectedSet());
for (IgtimiDeviceWithSecurityDTO device : selected) {
removeDevice(device, filteredDevices);
}
}
}
});
removeDeviceButton.setEnabled(false);
devicesCaptionPanelContents.add(devicesControlsPanel);
devicesCaptionPanelContents.add(devicesTable);
add(devicesCaptionPanel);
@@ -230,20 +231,19 @@ public class IgtimiDevicesPanel extends FlowPanel implements FilterablePanelProv
dawControlsPanel.add(dawButtonPanel);
dawButtonPanel.addUnsecuredAction(stringMessages.refresh(), () -> refreshDataAccessWindows());
// setup controls
final Button removeDAWButton = dawButtonPanel.addRemoveAction(stringMessages.remove(), refreshableDataAccessWindowsSelectionModel,
dawButtonPanel.addRemoveAction(stringMessages.remove(), refreshableDataAccessWindowsSelectionModel,
/* with confirmation */ true, () -> {
if (refreshableDataAccessWindowsSelectionModel.getSelectedSet().size() > 0) {
if (Window.confirm(stringMessages.doYouReallyWantToRemoveTheSelectedIgtimiDataAccessWindows())) {
for (IgtimiDataAccessWindowWithSecurityDTO daw : refreshableDataAccessWindowsSelectionModel.getSelectedSet()) {
final List<IgtimiDataAccessWindowWithSecurityDTO> selected = new ArrayList<>(refreshableDataAccessWindowsSelectionModel.getSelectedSet());
for (IgtimiDataAccessWindowWithSecurityDTO daw : selected) {
removeDataAccessWindow(daw, filteredDAWs);
}
}
}
});
removeDAWButton.setEnabled(false);
refreshableDevicesSelectionModel.addSelectionChangeHandler(
e -> {
removeDeviceButton.setEnabled(refreshableDevicesSelectionModel.getSelectedSet().size() > 0);
final boolean exactlyOneDeviceSelected = refreshableDevicesSelectionModel.getSelectedSet().size() == 1;
dawTable.setVisible(exactlyOneDeviceSelected);
dawControlsPanel.setVisible(exactlyOneDeviceSelected);
@@ -251,10 +251,6 @@ public class IgtimiDevicesPanel extends FlowPanel implements FilterablePanelProv
filterDataAccessWindowPanel.search(refreshableDevicesSelectionModel.getSelectedSet().iterator().next().getSerialNumber());
}
});
refreshableDataAccessWindowsSelectionModel.addSelectionChangeHandler(
e -> {
removeDAWButton.setEnabled(refreshableDataAccessWindowsSelectionModel.getSelectedSet().size() > 0);
});
dataAccessWindowsCaptionPanelContents.add(dawControlsPanel);
dataAccessWindowsCaptionPanelContents.add(dawTable);
add(dataAccessWindowsCaptionPanel);
@@ -168,7 +168,10 @@ public class LeaderboardConfigPanel extends AbstractLeaderboardConfigPanel
leaderboardCreateAndRegattaReadPermission, this::createRegattaLeaderboardWithOtherTieBreakingLeaderboard);
createRegattaLeaderboardWithOtherTieBreakingLeaderboardBtn.ensureDebugId("CreateRegattaLeaderboardWithOtherTieBreakingLeaderboardButton");
leaderboardRemoveButton = buttonPanel.addRemoveAction(stringMessages.remove(), leaderboardSelectionModel, true,
() -> removeLeaderboards(leaderboardSelectionModel.getSelectedSet()));
() -> {
final List<StrippedLeaderboardDTO> selectedLeaderboards = new ArrayList<>(leaderboardSelectionModel.getSelectedSet());
removeLeaderboards(selectedLeaderboards);
});
leaderboardRemoveButton.ensureDebugId("LeaderboardsRemoveButton");
buttonPanel.addUnsecuredWidget(new HelpButton(HelpButtonResources.INSTANCE,
stringMessages.videoGuide(), "https://sapsailing-documentation.s3-eu-west-1.amazonaws.com/adminconsole/Advanced+Topics/Leaderboard+Group+explained.mp4"));
@@ -865,14 +868,6 @@ public class LeaderboardConfigPanel extends AbstractLeaderboardConfigPanel
@Override
protected void leaderboardSelectionChanged() {
Set<StrippedLeaderboardDTO> selectedLeaderboards = leaderboardSelectionModel.getSelectedSet();
boolean canDeleteAllSelected = true;
for (StrippedLeaderboardDTO leaderboard : selectedLeaderboards) {
if (!userService.hasPermission(leaderboard, DefaultActions.DELETE)) {
canDeleteAllSelected = false;
}
}
leaderboardRemoveButton.setEnabled(!selectedLeaderboards.isEmpty() && canDeleteAllSelected);
final StrippedLeaderboardDTO selectedLeaderboard = getSelectedLeaderboard();
if (leaderboardSelectionModel.getSelectedSet().size() == 1 && selectedLeaderboard != null) {
raceColumnTable.getDataProvider().getList().clear();
@@ -610,7 +610,10 @@ public class LeaderboardGroupConfigPanel extends AbstractRegattaPanel
groupsTable.addColumnSortHandler(leaderboardGroupsListHandler);
refreshableGroupsSelectionModel = leaderboardTableSelectionColumn.getSelectionModel();
removeButton = buttonPanel.addRemoveAction(stringMessages.remove(), refreshableGroupsSelectionModel, true,
() -> removeLeaderboardGroups(refreshableGroupsSelectionModel.getSelectedSet()));
() -> {
final List<LeaderboardGroupDTO> selectedGroups = new ArrayList<>(refreshableGroupsSelectionModel.getSelectedSet());
removeLeaderboardGroups(selectedGroups);
});
removeButton.ensureDebugId("RemoveLeaderboardButton");
refreshableGroupsSelectionModel.addSelectionChangeHandler(event -> groupSelectionChanged());
groupsTable.setSelectionModel(refreshableGroupsSelectionModel, leaderboardTableSelectionColumn.getSelectionManager());
@@ -835,15 +838,8 @@ public class LeaderboardGroupConfigPanel extends AbstractRegattaPanel
}
private void groupSelectionChanged() {
Set<LeaderboardGroupDTO> selectedLeaderboardGroups = refreshableGroupsSelectionModel.getSelectedSet();
final Set<LeaderboardGroupDTO> selectedLeaderboardGroups = refreshableGroupsSelectionModel.getSelectedSet();
isSingleGroupSelected = selectedLeaderboardGroups.size() == 1;
boolean canDeleteAllSelected = true;
for (LeaderboardGroupDTO group : selectedLeaderboardGroups) {
if (!userService.hasPermission(group, DefaultActions.DELETE)) {
canDeleteAllSelected = false;
}
}
removeButton.setEnabled(!selectedLeaderboardGroups.isEmpty() && canDeleteAllSelected);
splitPanel.setVisible(isSingleGroupSelected);
if (isSingleGroupSelected) {
LeaderboardGroupDTO selectedGroup = selectedLeaderboardGroups.iterator().next();
@@ -32,12 +32,9 @@ import com.google.gwt.user.cellview.client.Header;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.view.client.ListDataProvider;
import com.google.gwt.view.client.SelectionChangeEvent;
import com.google.gwt.view.client.SelectionChangeEvent.Handler;
import com.sap.sailing.domain.common.RegattaAndRaceIdentifier;
import com.sap.sailing.domain.common.media.MediaTrack;
import com.sap.sailing.domain.common.media.MediaTrackWithSecurityDTO;
@@ -177,7 +174,7 @@ public class MediaPanel extends FlowPanel implements FilterablePanelProvider<Med
}).center();
}
});
final Button multiUrlChange = buttonAndFilterPanel.addUpdateAction(stringMessages.multiUrlChangeMediaTrack(),
buttonAndFilterPanel.addUpdateAction(stringMessages.multiUrlChangeMediaTrack(),
refreshableSelectionModel,
new Command() {
@Override
@@ -195,33 +192,16 @@ public class MediaPanel extends FlowPanel implements FilterablePanelProvider<Med
}
}
});
final Button remove = buttonAndFilterPanel.addRemoveAction(stringMessages.remove(), refreshableSelectionModel,
buttonAndFilterPanel.addRemoveAction(stringMessages.remove(), refreshableSelectionModel,
/* with confirmation */ true, new Command() {
@Override
public void execute() {
for (final MediaTrackWithSecurityDTO track : refreshableSelectionModel.getSelectedSet()) {
final List<MediaTrackWithSecurityDTO> selected = new ArrayList<>(refreshableSelectionModel.getSelectedSet());
for (final MediaTrackWithSecurityDTO track : selected) {
removeMediaTrack(track);
}
}
});
refreshableSelectionModel.addSelectionChangeHandler(new Handler() {
@Override
public void onSelectionChange(final SelectionChangeEvent event) {
final Set<MediaTrackWithSecurityDTO> selected = refreshableSelectionModel.getSelectedSet();
boolean canDeleteAllSelected = true;
boolean canUpdateAllSelected = true;
for (final MediaTrackWithSecurityDTO track : selected) {
if (!userService.hasPermission(track, DefaultActions.DELETE)) {
canDeleteAllSelected = false;
}
if (!userService.hasPermission(track, DefaultActions.UPDATE)) {
canUpdateAllSelected = false;
}
}
remove.setEnabled(!selected.isEmpty() && canDeleteAllSelected);
multiUrlChange.setEnabled(!selected.isEmpty() && canUpdateAllSelected);
}
});
buttonAndFilterPanel.addUnsecuredWidget(lblFilterRaces);
createMediaTracksTable(userService);
filterableMediaTracks.getTextBox().ensureDebugId("MediaTracksFilterTextBox");
@@ -61,7 +61,10 @@ public class RaceLogTrackingEventManagementRaceImagesBarCell extends ImagesBarCe
result.add(new ImageSpec(ACTION_SET_TRACKING_TIMES, stringMessages.setTrackingTimes(), makeImagePrototype(resources.setTrackingTimes())));
final boolean trackerExists = object.getA().getRaceLogTrackingInfo(object.getB()).raceLogTrackerExists;
final RaceLogTrackingState trackingState = object.getA().getRaceLogTrackingInfo(object.getB()).raceLogTrackingState;
if (trackingState == RaceLogTrackingState.AWAITING_RACE_DEFINITION || (trackingState == RaceLogTrackingState.TRACKING && !trackerExists)) {
// bug6251: also suppress "Start Tracking" when a tracked race is already linked to the slot (e.g. FINISHED after
// stopping tracking) starting would fail on the server because the tracked race would need to be removed first.
final boolean trackedRaceLinked = object.getA().isTrackedRace(object.getB());
if (!trackedRaceLinked && (trackingState == RaceLogTrackingState.AWAITING_RACE_DEFINITION || (trackingState == RaceLogTrackingState.TRACKING && !trackerExists))) {
result.add(new ImageSpec(ACTION_START_TRACKING, stringMessages.startTracking(), makeImagePrototype(resources.startRaceLogTracking())));
} else if (trackingState == RaceLogTrackingState.TRACKING && trackerExists) {
result.add(new ImageSpec(ACTION_STOP_TRACKING, stringMessages.stopTracking(), makeImagePrototype(resources.stopRaceLogTracking())));
@@ -15,7 +15,6 @@ import com.google.gwt.user.client.ui.CaptionPanel;
import com.google.gwt.user.client.ui.SimplePanel;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.view.client.SelectionChangeEvent;
import com.google.gwt.view.client.SelectionChangeEvent.Handler;
import com.sap.sailing.domain.common.RegattaIdentifier;
import com.sap.sailing.gwt.common.client.help.HelpButton;
import com.sap.sailing.gwt.common.client.help.HelpButtonResources;
@@ -29,7 +28,6 @@ import com.sap.sse.gwt.adminconsole.FilterablePanelProvider;
import com.sap.sse.gwt.client.ErrorReporter;
import com.sap.sse.gwt.client.celltable.RefreshableMultiSelectionModel;
import com.sap.sse.gwt.client.panels.AbstractFilterablePanel;
import com.sap.sse.security.shared.HasPermissions.DefaultActions;
import com.sap.sse.security.ui.client.UserService;
import com.sap.sse.security.ui.client.component.AccessControlledButtonPanel;
@@ -79,7 +77,7 @@ public class RegattaManagementPanel extends SimplePanel implements FilterablePan
update.ensureDebugId("UpdateRegattaButton");
final Button create = buttonPanel.addCreateAction(stringMessages.addRegatta(), this::openCreateRegattaDialog);
create.ensureDebugId("AddRegattaButton");
final Button remove = buttonPanel.addRemoveAction(stringMessages.remove(),
buttonPanel.addRemoveAction(stringMessages.remove(),
refreshableRegattaMultiSelectionModel, true, () -> {
// unmodifiable collection can't be sent to the server.
final Collection<RegattaIdentifier> regattas = createModifiableCollection();
@@ -88,10 +86,10 @@ public class RegattaManagementPanel extends SimplePanel implements FilterablePan
buttonPanel.addUnsecuredWidget(new HelpButton(HelpButtonResources.INSTANCE,
stringMessages.videoGuide(), "https://sapsailing-documentation.s3-eu-west-1.amazonaws.com/adminconsole/Advanced+Topics/Setting+up+Events+with+multiple+Regattas+or+Classes.mp4"));
regattasContentPanel.add(buttonPanel);
refreshableRegattaMultiSelectionModel.addSelectionChangeHandler(new Handler() {
refreshableRegattaMultiSelectionModel.addSelectionChangeHandler(new SelectionChangeEvent.Handler() {
@Override
public void onSelectionChange(SelectionChangeEvent event) {
List<RegattaDTO> selectedRegattas = new ArrayList<>(
final List<RegattaDTO> selectedRegattas = new ArrayList<>(
refreshableRegattaMultiSelectionModel.getSelectedSet());
final RegattaIdentifier selectedRegatta;
if (selectedRegattas.size() == 1) {
@@ -109,13 +107,6 @@ public class RegattaManagementPanel extends SimplePanel implements FilterablePan
regattaDetailsComposite.setRegatta(null);
regattaDetailsComposite.setVisible(false);
}
boolean canDeleteAllSelected = true;
for (RegattaDTO regatta : refreshableRegattaMultiSelectionModel.getSelectedSet()) {
if (!userService.hasPermission(regatta, DefaultActions.DELETE)) {
canDeleteAllSelected = false;
}
}
remove.setEnabled(!selectedRegattas.isEmpty() && canDeleteAllSelected);
}
});
regattasContentPanel.add(regattaListComposite);
@@ -1,6 +1,7 @@
package com.sap.sailing.gwt.ui.adminconsole;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -56,7 +57,10 @@ public class ResultImportUrlsListComposite extends Composite {
add.ensureDebugId("AddUrlButton");
add.setEnabled(false);
final Button remove = buttonPanel.addRemoveAction(stringMessages.remove(), table.getSelectionModel(),
/* withConfirmation */ true, () -> removeUrls(table.getSelectionModel().getSelectedSet()));
/* withConfirmation */ true, () -> {
final Set<UrlDTO> selected = new HashSet<>(table.getSelectionModel().getSelectedSet());
removeUrls(selected);
});
remove.ensureDebugId("RemoveUrlButton");
final Button refresh = buttonPanel.addUnsecuredAction(stringMessages.refresh(), this::updateTable);
refresh.ensureDebugId("RefreshUrlButton");
@@ -602,6 +602,11 @@ public class SmartphoneTrackingEventManagementPanel extends AbstractLeaderboardC
} else if (!getTrackingState(race).isForTracking()) {
allCanStart = false;
allCanStop = false;
// bug6251: a linked tracked race (e.g. FINISHED) has no active tracker but cannot be tracked again
// until the existing tracked race is removed first disable "Start Tracking" in that case.
} else if (race.getA().isTrackedRace(race.getB())) {
allCanStart = false;
allCanStop = false;
} else {
allCanStop = false;
}
@@ -100,8 +100,9 @@ public class CourseTemplatePanel extends FlowPanel implements FilterablePanelPro
buttonAndFilterPanel.addCreateAction(stringMessages.add(),
() -> openEditCourseTemplateDialog(new CourseTemplateDTO(), userService, true));
buttonAndFilterPanel.addRemoveAction(stringMessages.remove(), refreshableSelectionModel, true,
() -> removeCourseTemplates(refreshableSelectionModel.getSelectedSet().stream()
.map(courseTemplateDTO -> courseTemplateDTO.getUuid()).collect(Collectors.toList())));
() -> {final List<UUID> uuids = refreshableSelectionModel.getSelectedSet().stream().map(courseTemplateDTO -> courseTemplateDTO.getUuid()).collect(Collectors.toList());
removeCourseTemplates(uuids);
});
buttonAndFilterPanel.addUnsecuredWidget(lblFilterRaces);
buttonAndFilterPanel.addUnsecuredWidget(filterableCourseTemplatePanel);
filterableCourseTemplatePanel
@@ -110,8 +110,9 @@ public class MarkPropertiesPanel extends FlowPanel implements FilterablePanelPro
buttonAndFilterPanel.addCreateAction(stringMessages.add(),
() -> openEditMarkPropertiesDialog(new MarkPropertiesDTO()));
buttonAndFilterPanel.addRemoveAction(stringMessages.remove(), refreshableSelectionModel, true,
() -> removeMarkProperties(refreshableSelectionModel.getSelectedSet().stream()
.map(markPropertiesDTO -> markPropertiesDTO.getUuid()).collect(Collectors.toList())));
() -> { final List<UUID> uuids = refreshableSelectionModel.getSelectedSet().stream().map(markPropertiesDTO -> markPropertiesDTO.getUuid()).collect(Collectors.toList());
removeMarkProperties(uuids);
});
buttonAndFilterPanel.addUnsecuredWidget(lblFilterRaces);
filterableMarkProperties.getTextBox().ensureDebugId("MarkPropertiesFilterTextBox");
buttonAndFilterPanel.addUnsecuredWidget(filterableMarkProperties);
@@ -3,6 +3,7 @@ package com.sap.sailing.gwt.ui.shared;
import java.util.Date;
import com.sap.sailing.domain.common.security.SecuredDomainType;
import com.sap.sse.common.Named;
import com.sap.sse.common.TimePoint;
import com.sap.sse.security.shared.HasPermissions;
import com.sap.sse.security.shared.QualifiedObjectIdentifier;
@@ -12,7 +13,7 @@ import com.sap.sse.security.shared.dto.OwnershipDTO;
import com.sap.sse.security.shared.dto.SecuredDTO;
import com.sap.sse.security.shared.dto.SecurityInformationDTO;
public class IgtimiDataAccessWindowWithSecurityDTO implements SecuredDTO {
public class IgtimiDataAccessWindowWithSecurityDTO implements SecuredDTO, Named {
private static final long serialVersionUID = 176992188692729118L;
private SecurityInformationDTO securityInformation = new SecurityInformationDTO();
@@ -1,6 +1,7 @@
package com.sap.sailing.gwt.ui.shared;
import com.sap.sailing.domain.common.security.SecuredDomainType;
import com.sap.sse.common.Named;
import com.sap.sse.common.Position;
import com.sap.sse.common.TimePoint;
import com.sap.sse.common.Util.Pair;
@@ -12,7 +13,7 @@ import com.sap.sse.security.shared.dto.OwnershipDTO;
import com.sap.sse.security.shared.dto.SecuredDTO;
import com.sap.sse.security.shared.dto.SecurityInformationDTO;
public class IgtimiDeviceWithSecurityDTO implements SecuredDTO {
public class IgtimiDeviceWithSecurityDTO implements SecuredDTO, Named {
private static final long serialVersionUID = 176992188692729118L;
private SecurityInformationDTO securityInformation = new SecurityInformationDTO();
@@ -33,6 +33,8 @@ public class UserGroupRoleDefinitionPanelPO extends PageArea {
private static final String TABLE_ROLE_NAME_COLUMN = "Role Name";
@FindBy(how = BySeleniumId.class, using = "AddGroupUserButton")
private WebElement addRoleButton;
@FindBy(how = BySeleniumId.class, using = "RemoveRoleButton")
private WebElement removeRoleButton;
@FindBy(how = BySeleniumId.class, using = "RoleSuggestion")
private WebElement roleNameInput;
@FindBy(how = BySeleniumId.class, using = "GroupRoleDefinitionDTOTable")
@@ -85,8 +87,14 @@ public class UserGroupRoleDefinitionPanelPO extends PageArea {
}
public void removeRole(String name) {
final RoleEntryPO findRole = findRole(name);
findRole.deleteRole();
final RoleEntryPO role = findRole(name);
role.select();
removeRoleButton.click();
waitForAlertContainingMessageAndAccept("The following element(s) will be removed");
}
public void removeRoleViaActionButton(final String name) {
findRole(name).deleteRole();
waitForAlertContainingMessageAndAccept("Do you really want to remove role");
}
@@ -87,6 +87,19 @@ public class TestUserGroupCreation extends AbstractSeleniumTest {
@SeleniumTestCase
public void testRoleRemoval() {
final UserGroupManagementPanelPO userGroupManagementPanel = goToUserGroupDefinitionsPanel();
createGroup(userGroupManagementPanel);
userGroupManagementPanel.selectGroup(TEST_GROUP_NAME);
final UserGroupRoleDefinitionPanelPO userRolesPO = userGroupManagementPanel.getUserGroupRoles();
createRole(userRolesPO);
userGroupManagementPanel.selectGroup(TEST_GROUP_NAME);
userRolesPO.removeRoleViaActionButton(TEST_ROLE);
userGroupManagementPanel.selectGroup(TEST_GROUP_NAME);
assertNull(userRolesPO.findRole(TEST_ROLE));
}
@SeleniumTestCase
public void testRoleRemovalViaGlobalRemoveButton() {
final UserGroupManagementPanelPO userGroupManagementPanel = goToUserGroupDefinitionsPanel();
createGroup(userGroupManagementPanel);
userGroupManagementPanel.selectGroup(TEST_GROUP_NAME);
@@ -120,7 +120,12 @@ public class RefreshableMultiSelectionModel<T> extends MultiSelectionModelWithSe
* {@link AbstractSelectionModel#fireEvent(com.google.gwt.event.shared.GwtEvent)}.
*
* @param newObjects
* the new objects to refresh the {@link RefreshableMultiSelectionModel selection model}
* the new objects to refresh the {@link RefreshableMultiSelectionModel selection model}. When this
* selection model is used together with {@link com.sap.sse.gwt.client.panels.AbstractFilterablePanel},
* this must be the <em>unfiltered</em> list (i.e. the {@code all} {@link ListDataProvider}, not the
* {@code filtered} one), so that items hidden by the current filter are not incorrectly deselected.
* {@link HasDataAdapter} ensures this by passing {@code listDataProvider.getList()} which is the
* {@code all} provider registered in the {@code AbstractFilterablePanel} constructor.
*/
@Override
public void refreshSelectionModel(Iterable<T> newObjects) {
@@ -129,14 +134,26 @@ public class RefreshableMultiSelectionModel<T> extends MultiSelectionModelWithSe
try {
if (!isEmpty()) {
for (T it : newObjects) {
if (isSelected(it)) {
if (isSelected(it)) {
setSelected(it, true); // this updates matching elements in the selection model
}
}
// elements that were selected before and that don't have a corresponding element in newObjects
// will just be left alone; they will probably remain in selectedSet, and they were probably not in
// newObjects because a filter removed them. But when they re-appear, e.g., because the filter is
// removed, the elements will naturally be selected again.
// Deselect items that are no longer present in newObjects (e.g. because they were deleted).
// newObjects comes from getAllListDataProvider() (the unfiltered list), so absence here means
// true deletion, not just a filter hiding the item.
// getSelectedElements() already returns a snapshot copy, so no additional copy is needed here.
for (final T selected : getSelectedElements()) {
boolean foundInNew = false;
for (final T candidate : newObjects) {
if (comp != null ? comp.representSameEntity(selected, candidate) : selected.equals(candidate)) {
foundInNew = true;
break;
}
}
if (!foundInNew) {
super.setSelected(selected, false);
}
}
SelectionChangeEvent.fire(this);
}
} finally {
@@ -272,6 +272,7 @@ public abstract class AbstractFilterablePanel<T> extends HorizontalPanel {
* Removes an object and applies the search filter.
*/
public void remove(T object) {
select(object, false);
all.getList().remove(object);
filter();
}
@@ -287,11 +288,13 @@ public abstract class AbstractFilterablePanel<T> extends HorizontalPanel {
* <code>removeAll(all)</code> with <code>all</code> being a copy of what you get when calling {@link #getAll()}.
*/
public void removeAll() {
deselectAll();
all.getList().clear();
filter();
}
public void removeAll(Iterable<T> objects) {
objects.forEach(o->select(o, false)); // clear those objects removed from the selection
Util.removeAll(objects, all.getList());
filter();
}
@@ -3,19 +3,25 @@ package com.sap.sse.security.ui.client.component;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.HorizontalPanel;
import com.google.gwt.user.client.ui.TextBox;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.view.client.MultiSelectionModel;
import com.google.gwt.view.client.SetSelectionModel;
import com.sap.sse.common.Named;
import com.sap.sse.security.shared.HasPermissions;
import com.sap.sse.security.shared.HasPermissions.DefaultActions;
import com.sap.sse.security.shared.dto.SecuredDTO;
import com.sap.sse.security.ui.client.UserService;
import com.sap.sse.security.ui.client.i18n.StringMessages;
@@ -30,6 +36,7 @@ public class AccessControlledButtonPanel extends Composite {
private final HorizontalPanel panel = new HorizontalPanel();
private final Map<Button, Supplier<Boolean>> buttonToPermissions = new HashMap<>();
private final UserService userService;
private final Supplier<Boolean> createPermissionCheck, createPermissionCheckWithoutServerCreateObjectCheck,
removePermissionCheck, updatePermissionCheck;
private final BiConsumer<Button, Supplier<Boolean>> visibilityUpdater = (btn, check) -> btn.setVisible(check.get());
@@ -44,6 +51,7 @@ public class AccessControlledButtonPanel extends Composite {
* the {@link HasPermissions} representing the type of objects to be secured by this panel
*/
public AccessControlledButtonPanel(final UserService userService, final HasPermissions type) {
this.userService = userService;
this.createPermissionCheck = () -> userService.hasCurrentUserPermissionToCreateObjectOfType(type);
this.createPermissionCheckWithoutServerCreateObjectCheck = () -> userService
.hasCurrentUserPermissionToCreateObjectOfTypeWithoutServerCreateObjectPermissionCheck(type);
@@ -104,39 +112,103 @@ public class AccessControlledButtonPanel extends Composite {
}
/**
* Adds a secured action button, which is only visible if the current user has any
* {@link UserService#hasCurrentUserPermissionToDeleteAnyObjectOfType(HasPermissions) delete permission} for the
* {@link HasPermissions type} provided in this {@link AccessControlledButtonPanel}'s constructor.
* Like {@link #addRemoveAction(String, SetSelectionModel, boolean, Command)} but for rows that are not
* {@link SecuredDTO} instances themselves use this when the permission to remove entries is governed by a parent
* secured object rather than per-row permissions. The button is {@link Button#setEnabled(boolean) enabled} when the
* selection is non-empty and the current user has the specified {@code permissionAction} on the object supplied by
* {@code parentSecuredObject}; it shows the selected count in its label.
*
* @param text
* the {@link String text} to show on the button
* @param callback
* the {@link Command callback} to execute on button click, if permission is granted
* @return the created {@link Button} instance
*/
public Button addRemoveAction(final String text, final Command callback) {
return addAction(text, removePermissionCheck, callback);
}
/**
* Adds a secured action button, which is only visible if the current user has any
* {@link UserService#hasCurrentUserPermissionToDeleteAnyObjectOfType(HasPermissions) delete permission} for the
* {@link HasPermissions type} provided in this {@link AccessControlledButtonPanel}'s constructor.
* <p>Example: removing a role or user from a {@code UserGroup} is semantically an UPDATE to that group, so the
* caller should pass {@link DefaultActions#UPDATE} as {@code permissionAction} even though the button is labelled
* "Remove".
*
* @param text
* the {@link String text} to show on the button
* @param selectionModel
* the {@link SetSelectionModel<T> selection model} of the table; used to enable/disable the remove
* button when the selection becomes non-empty/empty, respectively and to display the number of elements
* selected in case the selection contains more than one element
* the {@link MultiSelectionModel} of the sub-table; drives the count shown in the button label and the
* enabled state
* @param parentSecuredObject
* supplies the parent {@link SecuredDTO} whose permission gates the button; may return {@code null} when
* nothing is selected, which disables the button
* @param permissionAction
* the {@link DefaultActions action} to check on the parent secured object (e.g.
* {@link DefaultActions#UPDATE} or {@link DefaultActions#DELETE})
* @param callback
* the {@link Command callback} to execute on button click, if permission is granted
* @return the created {@link Button} instance
*/
public <T> Button addCountingActionWithParentPermission(final String text, final MultiSelectionModel<T> selectionModel,
final Supplier<SecuredDTO> parentSecuredObject, final DefaultActions permissionAction, final Command callback) {
final Button button = resolveButtonVisibility(removePermissionCheck,
new Button(text, wrap(removePermissionCheck, callback)));
selectionModel.addSelectionChangeHandler(event -> {
final int count = selectionModel.getSelectedSet().size();
button.setText(count > 0 ? text + " (" + count + ")" : text);
button.setEnabled(count > 0 && userService.hasPermission(parentSecuredObject.get(), permissionAction));
});
button.setEnabled(false);
return button;
}
/**
* Like {@link #addRemoveAction(String, SetSelectionModel, boolean, Command)} but for rows that are not
* {@link SecuredDTO} instances themselves use this when the permission to remove entries is governed by a parent
* secured object rather than per-row permissions. A confirmation dialog is always shown before the {@code callback}
* is invoked, with the selected elements listed using the provided {@code nameMapper}.
*
* @param text
* the {@link String text} to show on the button
* @param selectionModel
* the {@link SetSelectionModel} of the sub-table; drives the count shown in the button label and the
* enabled state
* @param nameMapper
* maps each selected element to the {@link String} name to display in the confirmation message
* @param parentSecuredObject
* supplies the parent {@link SecuredDTO} whose permission gates the button; may return {@code null} when
* nothing is selected, which disables the button
* @param permissionAction
* the {@link DefaultActions action} to check on the parent secured object (e.g.
* {@link DefaultActions#UPDATE} or {@link DefaultActions#DELETE})
* @param callback
* the {@link Command callback} to execute on button click, if permission is granted and confirmed
* @return the created {@link Button} instance
*/
public <T> Button addRemoveActionWithParentPermission(final String text, final SetSelectionModel<T> selectionModel,
final Function<T, String> nameMapper, final Supplier<SecuredDTO> parentSecuredObject,
final DefaultActions permissionAction, final Command callback) {
final Command confirmingCallback = () -> {
final String names = selectionModel.getSelectedSet().stream().map(nameMapper)
.collect(Collectors.joining("\n"));
if (Window.confirm(StringMessages.INSTANCE.doYouReallyWantToRemoveSelectedElements(names))) {
callback.execute();
}
};
final Button button = resolveButtonVisibility(removePermissionCheck,
new Button(text, wrap(removePermissionCheck, confirmingCallback)));
selectionModel.addSelectionChangeHandler(event -> {
final int count = selectionModel.getSelectedSet().size();
button.setText(count > 0 ? text + " (" + count + ")" : text);
button.setEnabled(count > 0 && userService.hasPermission(parentSecuredObject.get(), permissionAction));
});
button.setEnabled(false);
return button;
}
/**
*
* @param text
* the {@link String text} to show on the button
* @param selectionModel
* the {@link SetSelectionModel} of the table; used to track the selected elements, display the count of
* selected elements in the button text, and drive the enabled state of the button
* @param withConfirmation
* the {@link Boolean} flag indicates whether to show confirmation or not
* when {@code true}, a confirmation dialog is shown before the {@code callback} is executed
* @param callback
* the {@link Command callback} to execute on button click, if permission is granted
*
* @return the created {@link SelectedElementsCountingButton} instance with optional confirmation
*/
public <T extends Named> Button addRemoveAction(final String text, final SetSelectionModel<T> selectionModel,
public <T extends Named & SecuredDTO> Button addRemoveAction(final String text, final SetSelectionModel<T> selectionModel,
boolean withConfirmation, final Command callback) {
if (selectionModel == null) {
throw new IllegalArgumentException("Selection model for a remove action must not be null");
@@ -146,6 +218,11 @@ public class AccessControlledButtonPanel extends Composite {
? new SelectedElementsCountingButton<T>(text, selectionModel, StringMessages.INSTANCE::doYouReallyWantToRemoveSelectedElements,
handler)
: new SelectedElementsCountingButton<T>(text, selectionModel, handler);
selectionModel.addSelectionChangeHandler(event -> {
final boolean canActOnAllSelected = selectionModel.getSelectedSet().stream()
.allMatch(item -> userService.hasPermission(item, DefaultActions.DELETE));
button.setEnabled(!selectionModel.getSelectedSet().isEmpty() && canActOnAllSelected);
});
return resolveButtonVisibility(removePermissionCheck, button);
}
@@ -165,28 +242,34 @@ public class AccessControlledButtonPanel extends Composite {
}
/**
* Adds a secured action button, which is only visible if the current user has any
* {@link UserService#hasCurrentUserPermissionToDeleteAnyObjectOfType(HasPermissions) update permission} for the
* {@link HasPermissions type} provided in this {@link AccessControlledButtonPanel}'s constructor.
* Adds a secured action button, which is only {@link Button#setVisible(boolean) visible} if the current user has
* any {@link UserService#hasCurrentUserPermissionToUpdateAnyObjectOfType(HasPermissions) update permission} for the
* {@link HasPermissions type} provided in this {@link AccessControlledButtonPanel}'s constructor, and which is only
* {@link Button#setEnabled(boolean) enabled} when the selection is non-empty and the current user has the
* {@link DefaultActions#UPDATE update permission} on every individually selected object.
*
* @param text
* the {@link String text} to show on the button
* @param selectionModel
* the {@link SetSelectionModel<T> selection model} of the table; used to enable/disable the remove
* button when the selection becomes non-empty/empty, respectively and to display the number of elements
* selected in case the selection contains more than one element
* the {@link SetSelectionModel} of the table; used to track the selected elements, display the count of
* selected elements in the button text, and drive the enabled state of the button
* @param callback
* the {@link Command callback} to execute on button click, if permission is granted
*
* @return the created {@link SelectedElementsCountingButton} instance with optional confirmation
* @return the created {@link SelectedElementsCountingButton} instance
*/
public <T extends Named> Button addUpdateAction(final String text, final SetSelectionModel<T> selectionModel,
public <T extends Named & SecuredDTO> Button addUpdateAction(final String text, final SetSelectionModel<T> selectionModel,
final Command callback) {
if (selectionModel == null) {
throw new IllegalArgumentException("Selection model for an update action must not be null");
}
final ClickHandler handler = wrap(updatePermissionCheck, callback);
final Button button = new SelectedElementsCountingButton<T>(text, selectionModel, handler);
selectionModel.addSelectionChangeHandler(event -> {
final boolean canActOnAllSelected = selectionModel.getSelectedSet().stream()
.allMatch(item -> userService.hasPermission(item, DefaultActions.UPDATE));
button.setEnabled(!selectionModel.getSelectedSet().isEmpty() && canActOnAllSelected);
});
return resolveButtonVisibility(updatePermissionCheck, button);
}
@@ -30,6 +30,7 @@ import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.view.client.ListDataProvider;
import com.google.gwt.view.client.SetSelectionModel;
import com.sap.sse.common.Util;
import com.sap.sse.common.util.NaturalComparator;
import com.sap.sse.gwt.client.ErrorReporter;
@@ -101,18 +102,17 @@ public class RoleDefinitionsPanel extends VerticalPanel {
roleDefinitionsTable.ensureDebugId("RolesCellTable");
filterablePanelRoleDefinitions.getTextBox().ensureDebugId("RolesFilterTextBox");
refreshableRoleDefinitionMultiSelectionModel = (RefreshableMultiSelectionModel<? super RoleDefinitionDTO>) roleDefinitionsTable.getSelectionModel();
@SuppressWarnings("unchecked")
final SetSelectionModel<RoleDefinitionDTO> roleSelectionModel = (SetSelectionModel<RoleDefinitionDTO>) roleDefinitionsTable.getSelectionModel();
final AccessControlledButtonPanel buttonPanel = new AccessControlledButtonPanel(userService, ROLE_DEFINITION);
buttonPanel.addUnsecuredAction(stringMessages.refresh(), this::updateRoleDefinitions);
final Button createButton = buttonPanel.addCreateActionWithoutServerCreateObjectPermissionCheck(stringMessages.add(),
this::createRoleDefinition);
createButton.ensureDebugId("CreateRoleButton");
final Button removeButton = buttonPanel.addRemoveAction(stringMessages.remove(), () -> {
final String roles = String.join(", ", Util.map(getSelectedRoleDefinitions(), RoleDefinitionDTO::getName));
if (Window.confirm(stringMessages.doYouReallyWantToRemoveRole(roles))) {
final Set<RoleDefinitionDTO> selectedRoles = new HashSet<>(getSelectedRoleDefinitions());
filterablePanelRoleDefinitions.removeAll(selectedRoles);
}
final Button removeButton = buttonPanel.addRemoveAction(stringMessages.remove(), roleSelectionModel, true,
() -> {
final Set<RoleDefinitionDTO> selectedRoles = new HashSet<>(getSelectedRoleDefinitions());
filterablePanelRoleDefinitions.removeAll(selectedRoles);
});
removeButton.ensureDebugId("RemoveRoleButton");
add(buttonPanel);
@@ -26,6 +26,7 @@ import com.sap.sse.gwt.client.celltable.TableWrapper;
import com.sap.sse.gwt.client.panels.LabeledAbstractFilterablePanel;
import com.sap.sse.security.shared.dto.StrippedRoleDefinitionDTO;
import com.sap.sse.security.shared.dto.UserGroupDTO;
import com.sap.sse.security.shared.dto.SecuredDTO;
import com.sap.sse.security.ui.client.UserManagementWriteServiceAsync;
import com.sap.sse.security.ui.client.UserService;
import com.sap.sse.security.ui.client.component.AccessControlledButtonPanel;
@@ -122,14 +123,14 @@ public class GroupRoleDefinitionPanel extends Composite
}
});
addButton.ensureDebugId("AddGroupUserButton");
final Button removeButton = buttonPanel.addUpdateAction(stringMessages.removeRole(), () -> {
Pair<StrippedRoleDefinitionDTO, Boolean> selectedRole = roleDefinitionTableWrapper.getSelectionModel()
.getSelectedObject();
if (selectedRole == null) {
Window.alert(stringMessages.youHaveToSelectAUserGroup());
} else if (Window.confirm(stringMessages.doYouReallyWantToRemoveRole(selectedRole.getA().getName()))) {
UserGroupDTO selectedObject = TableWrapper.getSingleSelectedObjectOrNull(userGroupSelectionModel);
if (selectedObject != null) {
// Removing a role from a group is semantically an UPDATE to the UserGroup, not a per-role DELETE.
final Button removeButton = buttonPanel.addRemoveActionWithParentPermission(stringMessages.removeRole(),
roleDefinitionTableWrapper.getSelectionModel(),
pair -> pair.getA().getName(),
() -> (SecuredDTO) TableWrapper.getSingleSelectedObjectOrNull(userGroupSelectionModel), UPDATE, () -> {
final UserGroupDTO selectedObject = TableWrapper.getSingleSelectedObjectOrNull(userGroupSelectionModel);
if (selectedObject != null) {
for (final Pair<StrippedRoleDefinitionDTO, Boolean> selectedRole : roleDefinitionTableWrapper.getSelectionModel().getSelectedElements()) {
userManagementService.removeRoleDefinitionFromUserGroup(selectedObject.getId().toString(),
selectedRole.getA().getId().toString(), new AsyncCallback<Void>() {
@Override
@@ -143,14 +144,10 @@ public class GroupRoleDefinitionPanel extends Composite
updateUserGroups();
}
});
} else {
Window.alert(stringMessages.pleaseSelect());
}
}
});
roleDefinitionTableWrapper.getSelectionModel().addSelectionChangeHandler(event -> removeButton
.setEnabled(!roleDefinitionTableWrapper.getSelectionModel().getSelectedSet().isEmpty()));
removeButton.setEnabled(false);
removeButton.ensureDebugId("RemoveRoleButton");
buttonPanel.insertWidgetAtPosition(suggestRole, 0);
return buttonPanel;
}
@@ -17,7 +17,7 @@ import com.sap.sse.gwt.client.ErrorReporter;
import com.sap.sse.gwt.client.celltable.AbstractSortableTextColumn;
import com.sap.sse.gwt.client.celltable.CellTableWithCheckboxResources;
import com.sap.sse.gwt.client.celltable.EntityIdentityComparator;
import com.sap.sse.gwt.client.celltable.RefreshableSingleSelectionModel;
import com.sap.sse.gwt.client.celltable.RefreshableMultiSelectionModel;
import com.sap.sse.gwt.client.celltable.TableWrapper;
import com.sap.sse.gwt.client.panels.LabeledAbstractFilterablePanel;
import com.sap.sse.security.shared.dto.StrippedRoleDefinitionDTO;
@@ -31,7 +31,7 @@ import com.sap.sse.security.ui.client.i18n.StringMessages;
* Name and whether the role is enabled for all users. There is also an options to delete the group.
*/
public class RoleDefinitionTableWrapper extends
TableWrapper<Pair<StrippedRoleDefinitionDTO, Boolean>, RefreshableSingleSelectionModel<Pair<StrippedRoleDefinitionDTO, Boolean>>, StringMessages, CellTableWithCheckboxResources> {
TableWrapper<Pair<StrippedRoleDefinitionDTO, Boolean>, RefreshableMultiSelectionModel<Pair<StrippedRoleDefinitionDTO, Boolean>>, StringMessages, CellTableWithCheckboxResources> {
private final LabeledAbstractFilterablePanel<Pair<StrippedRoleDefinitionDTO, Boolean>> filterField;
private final MultiSelectionModel<UserGroupDTO> userGroupSelectionModel;
@@ -39,7 +39,7 @@ public class RoleDefinitionTableWrapper extends
public RoleDefinitionTableWrapper(UserService userService, StringMessages stringMessages,
ErrorReporter errorReporter, boolean enablePager, CellTableWithCheckboxResources tableResources,
Runnable refresher, MultiSelectionModel<UserGroupDTO> userGroupSelectionModel) {
super(stringMessages, errorReporter, false, enablePager,
super(stringMessages, errorReporter, /* multiSelection */ true, enablePager,
new EntityIdentityComparator<Pair<StrippedRoleDefinitionDTO, Boolean>>() {
@Override
public boolean representSameEntity(Pair<StrippedRoleDefinitionDTO, Boolean> dto1,
@@ -22,9 +22,11 @@ import com.google.gwt.view.client.SelectionChangeEvent.Handler;
import com.sap.sse.common.Util;
import com.sap.sse.gwt.client.ErrorReporter;
import com.sap.sse.gwt.client.celltable.CellTableWithCheckboxResources;
import com.sap.sse.gwt.client.celltable.RefreshableMultiSelectionModel;
import com.sap.sse.gwt.client.celltable.TableWrapper;
import com.sap.sse.security.shared.dto.StrippedUserDTO;
import com.sap.sse.security.shared.dto.UserGroupDTO;
import com.sap.sse.security.shared.dto.SecuredDTO;
import com.sap.sse.security.ui.client.UserManagementWriteServiceAsync;
import com.sap.sse.security.ui.client.UserService;
import com.sap.sse.security.ui.client.component.AccessControlledButtonPanel;
@@ -97,16 +99,15 @@ public class UserGroupDetailPanel extends Composite
});
addButton.ensureDebugId("AddUserButton");
// add remove button
final Button removeButton = buttonPanel.addUpdateAction(stringMessages.actionRemove(), () -> {
// Removing a user from a group is semantically an UPDATE to the UserGroup, not a per-user DELETE.
buttonPanel.addCountingActionWithParentPermission(stringMessages.actionRemove(),
tenantUsersTable.getSelectionModel(),
() -> (SecuredDTO) TableWrapper.getSingleSelectedObjectOrNull(userGroupSelectionModel), UPDATE, () -> {
final Set<UserGroupDTO> selectedUserGroups = userGroupSelectionModel.getSelectedSet();
if (selectedUserGroups != null && selectedUserGroups.size() == 1) {
final UserGroupDTO selectedUserGroup = selectedUserGroups.iterator().next();
Set<StrippedUserDTO> users = tenantUsersTable.getSelectionModel().getSelectedSet();
if (selectedUserGroups == null || selectedUserGroups.isEmpty()) {
Window.alert(stringMessages.youHaveToSelectAUserGroup());
return;
}
for (StrippedUserDTO user : users) {
final RefreshableMultiSelectionModel<StrippedUserDTO> usersSelectionModel = tenantUsersTable.getSelectionModel();
for (final StrippedUserDTO user : usersSelectionModel.getSelectedElements()) {
final String username = user.getName();
userManagementService.removeUserFromUserGroup(selectedUserGroup.getId().toString(), username,
new AsyncCallback<Void>() {
@@ -134,9 +135,6 @@ public class UserGroupDetailPanel extends Composite
}
}
});
tenantUsersTable.getSelectionModel().addSelectionChangeHandler(
event -> removeButton.setEnabled(!tenantUsersTable.getSelectionModel().getSelectedSet().isEmpty()));
removeButton.setEnabled(false);
return buttonPanel;
}
@@ -84,11 +84,12 @@ extends TableWrapper<UserDTO, S, StringMessages, TR> {
new EntityIdentityComparator<UserDTO>() {
@Override
public boolean representSameEntity(UserDTO dto1, UserDTO dto2) {
return dto1.getId().toString().equals(dto2.getId().toString());
return Util.equalsWithNull(dto1.getId() != null ? dto1.getId().toString() : null,
dto2.getId() != null ? dto2.getId().toString() : null);
}
@Override
public int hashCode(UserDTO t) {
return t.getId().hashCode();
return t.getId() != null ? t.getId().hashCode() : 0;
}
}, tableResources);
this.userService = userService;
+2 -2
View File
@@ -249,8 +249,7 @@ Install the GWT Browser Plugin for the GWT Development mode. As of 2016-08-31 Fi
### Create Hudson Job
If you want a hudson job to run when you push your branch then you can run a script in `configuration` called `createHudsonJobForBug.sh`. For you bug branch titled `bug<bug number>`, create a build job, which will create a release, by running the script like so: `./createHudsonJobForBug.sh <bug number>`.
If you'd like the script to include the bug's summary in its description, set your BUGZILLA_API_KEY environment variable to an API key you obtain from [https://bugzilla.sapsailing.com/bugzilla/userprefs.cgi?tab=apikey](https://bugzilla.sapsailing.com/bugzilla/userprefs.cgi?tab=apikey) or pass the API key as the second argument, after the bug ID, as in
`./configuration/createHudsonJobForBug.sh <bug number> {Bugzilla-API-Key}`
The script will include the issue's summary in its description.
If on Windows, you may need to disable any web shields in antivirus software, to allow `curl` to function. If on Mac, you may need to install gnu-sed (``gsed``) via Homebrew.
### Issues when playing around with AWS
@@ -263,4 +262,5 @@ Solution: This was occurring because the website didn't have any content in the
### Extra Reading
Check out [refactoring patterns](https://refactoring.guru/) as a sort of cheatsheet for the aforementioned design patterns books.
The [Pragmatic Programmer](https://en.wikipedia.org/wiki/The_Pragmatic_Programmer) is a great read too.
We also have an [onboarding glossary](https://wiki.sapsailing.com/wiki/howto/glossary).
@@ -1,4 +1,4 @@
# Setup locally hosted 360° Videos
# Setup locally hosted 360°
[[_TOC_]]
## Installing the Webserver
@@ -54,7 +54,7 @@ If you are on Linux you can:<br>
* Following a refresh in the browser (F5) the video file should appear in the list:
![](nginx-a.JPG)
<br><br>
* Click on it to verify it is reachable, note that depending on the browser 360° videos will look distorted.
* Click on it to verify it is reachable, note that depending on the browser 360° videos will look distorted.
![](nginx-b.JPG)
<br><br>